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,1024 @@
|
|
|
1
|
+
"""Tracked-change markup for one paragraph: ``w:ins``, ``w:del`` and paragraph marks.
|
|
2
|
+
|
|
3
|
+
:class:`ParagraphEditor` edits a paragraph element from
|
|
4
|
+
:meth:`doc_control.ooxml.splice.DocumentSpans.parse_paragraph` and writes every
|
|
5
|
+
change as a revision, so rejecting the revisions restores the original text and
|
|
6
|
+
accepting them gives the new text.
|
|
7
|
+
|
|
8
|
+
Markup written
|
|
9
|
+
Deleted text keeps its run (and the run's ``w:rPr``) and the run moves into a
|
|
10
|
+
``w:del`` with ``w:t`` renamed ``w:delText``. Inserted text is one run inside
|
|
11
|
+
a ``w:ins``; its ``w:rPr`` is a copy of the run at the start of the range.
|
|
12
|
+
A deleted paragraph has ``w:pPr/w:rPr/w:del`` on its mark, an inserted one
|
|
13
|
+
``w:pPr/w:rPr/w:ins``. A run is split where a range starts or ends; the new
|
|
14
|
+
half copies the run's attributes and ``w:rPr``. Text inside someone else's
|
|
15
|
+
``w:ins`` is deleted with a ``w:del`` nested in it (what Word writes); new
|
|
16
|
+
text is never nested there: the foreign ``w:ins`` is split in two, the
|
|
17
|
+
continuation getting a fresh id, author and date copied.
|
|
18
|
+
|
|
19
|
+
What a range may cover
|
|
20
|
+
Only runs made of text (``w:t``, ``w:tab``, ``w:br``, ``w:cr``, hyphens,
|
|
21
|
+
``w:sym``, ``w:ptab``) directly in the paragraph or in a ``w:ins``, outside
|
|
22
|
+
fields. Text in a hyperlink, field, smart tag, content control, ruby or
|
|
23
|
+
alternate content, a run holding a drawing, a footnote reference or a field
|
|
24
|
+
character, or such a run between the two ends of the range, raises
|
|
25
|
+
``UNSUPPORTED_CONTENT`` (spec §Risks). Bookmark and comment-range markers,
|
|
26
|
+
proof marks and existing deletions between the ends stay where they are.
|
|
27
|
+
|
|
28
|
+
Bookmarks in a move
|
|
29
|
+
A moved range carries the bookmarks that start and end inside it (zero
|
|
30
|
+
width ones included): the markers leave the deleted originals and are
|
|
31
|
+
recreated, same name and a fresh id, inside the inserted copy. Accepting
|
|
32
|
+
leaves one live bookmark at the new place; rejecting removes the copy, and
|
|
33
|
+
with it the bookmark. ``_GoBack`` is dropped (Word recreates it). A bookmark
|
|
34
|
+
that starts or ends outside the range, and any comment anchor, is refused.
|
|
35
|
+
Trade-off: the references to a carried bookmark (REF/PAGEREF fields, TOC
|
|
36
|
+
``\\h`` entries and link anchors) keep working after accepting but dangle
|
|
37
|
+
after rejecting, so :func:`referenced_bookmarks` feeds a warning.
|
|
38
|
+
|
|
39
|
+
Ids
|
|
40
|
+
:class:`RevisionContext` hands out ``w:id`` values above every ``w:id`` in
|
|
41
|
+
the document, comments, footnotes, endnotes, headers and footers, so a new
|
|
42
|
+
revision never shares an id with a revision, comment or bookmark.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import copy
|
|
48
|
+
import re
|
|
49
|
+
import xml.etree.ElementTree as ET
|
|
50
|
+
from collections.abc import Callable, Sequence
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from datetime import UTC, datetime
|
|
53
|
+
|
|
54
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
55
|
+
from doc_control.model.index import Paragraph, Piece
|
|
56
|
+
from doc_control.ooxml.formatting import apply_ppr, apply_rpr
|
|
57
|
+
from doc_control.ooxml.splice import W_NS, XML_NS
|
|
58
|
+
from doc_control.ooxml.zipio import Package
|
|
59
|
+
|
|
60
|
+
W = f"{{{W_NS}}}"
|
|
61
|
+
XML_SPACE = f"{{{XML_NS}}}space"
|
|
62
|
+
|
|
63
|
+
P, PPR, RPR, R = W + "p", W + "pPr", W + "rPr", W + "r"
|
|
64
|
+
T, DELTEXT, INS, DEL = W + "t", W + "delText", W + "ins", W + "del"
|
|
65
|
+
BR = W + "br"
|
|
66
|
+
ID, AUTHOR, DATE = W + "id", W + "author", W + "date"
|
|
67
|
+
|
|
68
|
+
DEFAULT_AUTHOR = "Claude"
|
|
69
|
+
MAX_ID = 2**31 - 1
|
|
70
|
+
STORY_PART = re.compile(r"^word/(document|comments|footnotes|endnotes|header\d*|footer\d*)\.xml$")
|
|
71
|
+
|
|
72
|
+
_TEXT_TOKENS = frozenset(
|
|
73
|
+
W + name for name in ("t", "tab", "cr", "noBreakHyphen", "softHyphen", "sym", "ptab", "br")
|
|
74
|
+
)
|
|
75
|
+
_QUIET_TOKENS = frozenset({RPR, W + "lastRenderedPageBreak"})
|
|
76
|
+
_INERT_TOKENS = _QUIET_TOKENS | {W + "commentReference", W + "annotationRef"}
|
|
77
|
+
_INERT_ELEMENTS = frozenset(
|
|
78
|
+
W + name
|
|
79
|
+
for name in (
|
|
80
|
+
"bookmarkStart",
|
|
81
|
+
"bookmarkEnd",
|
|
82
|
+
"commentRangeStart",
|
|
83
|
+
"commentRangeEnd",
|
|
84
|
+
"permStart",
|
|
85
|
+
"permEnd",
|
|
86
|
+
"proofErr",
|
|
87
|
+
"moveFromRangeStart",
|
|
88
|
+
"moveFromRangeEnd",
|
|
89
|
+
"moveToRangeStart",
|
|
90
|
+
"moveToRangeEnd",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
BOOKMARK_START, BOOKMARK_END = W + "bookmarkStart", W + "bookmarkEnd"
|
|
94
|
+
GOBACK = "_GoBack"
|
|
95
|
+
_MARK_TAGS = frozenset({INS, DEL, W + "moveFrom", W + "moveTo", W + "rPrChange"})
|
|
96
|
+
_PAGE_BREAKS = frozenset({"page", "column"})
|
|
97
|
+
_VANISH_OFF = frozenset({"0", "false", "off"})
|
|
98
|
+
_PAGE_BREAK_TOKEN = "page-break"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_date(now: datetime | None = None) -> str:
|
|
102
|
+
"""``now`` (default: the current time) as ISO 8601 UTC with ``Z``, to the second."""
|
|
103
|
+
moment = datetime.now(UTC) if now is None else now
|
|
104
|
+
if moment.tzinfo is None:
|
|
105
|
+
raise ValueError("revision date must be timezone-aware")
|
|
106
|
+
return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def format_author(author: str, tag: str | None) -> str:
|
|
110
|
+
"""The ``w:author`` value: the author, with the per-call tag in parentheses if given."""
|
|
111
|
+
return f"{author} ({tag})" if tag else author
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def used_ids(pkg: Package) -> set[int]:
|
|
115
|
+
"""Every numeric ``w:id`` in the package's story parts (revisions, comments, bookmarks…)."""
|
|
116
|
+
ids: set[int] = set()
|
|
117
|
+
for name in pkg.names:
|
|
118
|
+
if not STORY_PART.match(name):
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
root = ET.fromstring(pkg.read(name))
|
|
122
|
+
except ET.ParseError as exc:
|
|
123
|
+
raise DocControlError(
|
|
124
|
+
ErrorCode.NOT_A_DOCX, f"{name} does not parse: {exc}", part=name
|
|
125
|
+
) from exc
|
|
126
|
+
for elem in root.iter():
|
|
127
|
+
raw = elem.get(ID)
|
|
128
|
+
if raw is None:
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
ids.add(int(raw.strip()))
|
|
132
|
+
except ValueError:
|
|
133
|
+
continue
|
|
134
|
+
return ids
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class RevisionContext:
|
|
139
|
+
"""Author, date and the id counter shared by every revision one call writes.
|
|
140
|
+
|
|
141
|
+
``revision_ids`` lists the ids of the revisions the call authored (what
|
|
142
|
+
"reject every revision the tool wrote" means); ``continuation_ids`` the ids
|
|
143
|
+
given to the second half of someone else's revision that had to be split.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
used: set[int]
|
|
147
|
+
author: str
|
|
148
|
+
date: str
|
|
149
|
+
revision_ids: list[str] = field(default_factory=list)
|
|
150
|
+
continuation_ids: list[str] = field(default_factory=list)
|
|
151
|
+
_next: int = field(init=False, default=1)
|
|
152
|
+
|
|
153
|
+
def __post_init__(self) -> None:
|
|
154
|
+
self._next = max(max(self.used, default=0), 0) + 1
|
|
155
|
+
|
|
156
|
+
def _allocate(self) -> str:
|
|
157
|
+
value = self._next
|
|
158
|
+
if value > MAX_ID:
|
|
159
|
+
raise DocControlError(ErrorCode.INTERNAL, "no revision ids left in this document")
|
|
160
|
+
self._next += 1
|
|
161
|
+
return str(value)
|
|
162
|
+
|
|
163
|
+
def revision(self, kind: str) -> ET.Element:
|
|
164
|
+
"""A new ``w:ins`` or ``w:del`` carrying id, author and date."""
|
|
165
|
+
rid = self._allocate()
|
|
166
|
+
self.revision_ids.append(rid)
|
|
167
|
+
return ET.Element(W + kind, {ID: rid, AUTHOR: self.author, DATE: self.date})
|
|
168
|
+
|
|
169
|
+
def continuation_id(self) -> str:
|
|
170
|
+
rid = self._allocate()
|
|
171
|
+
self.continuation_ids.append(rid)
|
|
172
|
+
return rid
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def unsupported(message: str, **details: object) -> DocControlError:
|
|
176
|
+
return DocControlError(ErrorCode.UNSUPPORTED_CONTENT, message, **details)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# -- small element helpers ------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _elements(node: ET.Element) -> list[ET.Element]:
|
|
183
|
+
"""Child elements, leaving out comments and processing instructions."""
|
|
184
|
+
return [c for c in node if isinstance(c.tag, str)]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _set_space(t: ET.Element) -> None:
|
|
188
|
+
text = t.text or ""
|
|
189
|
+
if text != text.strip():
|
|
190
|
+
t.set(XML_SPACE, "preserve")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _text_element(tag: str, text: str) -> ET.Element:
|
|
194
|
+
elem = ET.Element(tag)
|
|
195
|
+
elem.text = text
|
|
196
|
+
_set_space(elem)
|
|
197
|
+
return elem
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _tokens(run: ET.Element) -> set[str]:
|
|
201
|
+
out: set[str] = set()
|
|
202
|
+
for child in run:
|
|
203
|
+
if not isinstance(child.tag, str):
|
|
204
|
+
continue
|
|
205
|
+
if child.tag == BR and child.get(W + "type") in _PAGE_BREAKS:
|
|
206
|
+
out.add(_PAGE_BREAK_TOKEN)
|
|
207
|
+
else:
|
|
208
|
+
out.add(child.tag)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _hidden(run: ET.Element) -> bool:
|
|
213
|
+
rpr = run.find(RPR)
|
|
214
|
+
vanish = rpr.find(W + "vanish") if rpr is not None else None
|
|
215
|
+
return vanish is not None and vanish.get(W + "val", "1").lower() not in _VANISH_OFF
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _run_kind(run: ET.Element) -> str:
|
|
219
|
+
"""``text``, ``inert`` (nothing to read or delete), ``hidden`` or ``other`` (structure)."""
|
|
220
|
+
tokens = _tokens(run)
|
|
221
|
+
plain = tokens <= (_TEXT_TOKENS | _QUIET_TOKENS)
|
|
222
|
+
if _hidden(run):
|
|
223
|
+
return "hidden" if tokens <= (_TEXT_TOKENS | _INERT_TOKENS) else "other"
|
|
224
|
+
if plain and tokens & _TEXT_TOKENS:
|
|
225
|
+
return "text"
|
|
226
|
+
if tokens <= _INERT_TOKENS:
|
|
227
|
+
return "inert"
|
|
228
|
+
return "other"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _item_kind(node: ET.Element) -> str:
|
|
232
|
+
if node.tag == R:
|
|
233
|
+
return _run_kind(node)
|
|
234
|
+
if node.tag in _INERT_ELEMENTS or node.tag == DEL:
|
|
235
|
+
return "inert"
|
|
236
|
+
return "other"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _flat(p: ET.Element) -> list[tuple[ET.Element, ET.Element]]:
|
|
240
|
+
"""``(parent, node)`` for the paragraph's content in order, entering ``w:ins`` wrappers."""
|
|
241
|
+
items: list[tuple[ET.Element, ET.Element]] = []
|
|
242
|
+
for child in _elements(p):
|
|
243
|
+
if child.tag == PPR:
|
|
244
|
+
continue
|
|
245
|
+
if child.tag == INS:
|
|
246
|
+
items.extend((child, sub) for sub in _elements(child))
|
|
247
|
+
else:
|
|
248
|
+
items.append((p, child))
|
|
249
|
+
return items
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
_FIELD_TARGET = re.compile(r'\b(?:REF|PAGEREF|NOTEREF)\s+"?([^\s"\\]+)')
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def referenced_bookmarks(pkg: Package) -> set[str]:
|
|
256
|
+
"""Names that a REF/PAGEREF/NOTEREF field (simple or complex) or a link ``w:anchor`` targets.
|
|
257
|
+
|
|
258
|
+
A TOC ``\\h`` entry is such a link. Looked up in every story part.
|
|
259
|
+
"""
|
|
260
|
+
found: set[str] = set()
|
|
261
|
+
for name in pkg.names:
|
|
262
|
+
if not STORY_PART.match(name):
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
root = ET.fromstring(pkg.read(name))
|
|
266
|
+
except ET.ParseError:
|
|
267
|
+
continue
|
|
268
|
+
for elem in root.iter():
|
|
269
|
+
if elem.tag == W + "hyperlink" and elem.get(W + "anchor"):
|
|
270
|
+
found.add(elem.get(W + "anchor", ""))
|
|
271
|
+
elif elem.tag == W + "fldSimple":
|
|
272
|
+
found.update(_FIELD_TARGET.findall(elem.get(W + "instr", "")))
|
|
273
|
+
elif elem.tag == P:
|
|
274
|
+
instr = "".join(t.text or "" for t in elem.iter(W + "instrText"))
|
|
275
|
+
found.update(_FIELD_TARGET.findall(instr))
|
|
276
|
+
return found
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class BookmarkScope:
|
|
280
|
+
"""The bookmarks of a moved range of paragraphs, checked and given their new ids.
|
|
281
|
+
|
|
282
|
+
``paragraphs`` are the range's (unedited) elements, ``addresses`` their
|
|
283
|
+
addresses for messages, ``name_of`` finds the name of a bookmark by id when
|
|
284
|
+
only its end is in the range. A bookmark must start and end inside the range
|
|
285
|
+
(a zero width one does) or the move is refused; ``_GoBack`` is dropped.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
paragraphs: Sequence[ET.Element],
|
|
291
|
+
addresses: Sequence[str],
|
|
292
|
+
ctx: RevisionContext,
|
|
293
|
+
name_of: Callable[[str], str | None],
|
|
294
|
+
) -> None:
|
|
295
|
+
self.ctx = ctx
|
|
296
|
+
self.dropped: set[str] = set()
|
|
297
|
+
self.carried: dict[str, str] = {} # old id -> new id, allocated on first use
|
|
298
|
+
self.names: list[str] = [] # names of the carried bookmarks
|
|
299
|
+
open_: dict[str, str] = {}
|
|
300
|
+
for p, address in zip(paragraphs, addresses, strict=True):
|
|
301
|
+
reachable = {id(node) for _, node in _flat(p)}
|
|
302
|
+
for elem in p.iter():
|
|
303
|
+
if elem.tag not in (BOOKMARK_START, BOOKMARK_END):
|
|
304
|
+
continue
|
|
305
|
+
bid = elem.get(ID, "")
|
|
306
|
+
if id(elem) not in reachable:
|
|
307
|
+
name = elem.get(W + "name") or name_of(bid) or f"id {bid}"
|
|
308
|
+
raise unsupported(
|
|
309
|
+
f"bookmark {name!r} sits inside a deletion or other structure, "
|
|
310
|
+
"which a move cannot carry",
|
|
311
|
+
paragraph=address,
|
|
312
|
+
bookmark=name,
|
|
313
|
+
)
|
|
314
|
+
if elem.tag == BOOKMARK_START:
|
|
315
|
+
open_[bid] = elem.get(W + "name", "")
|
|
316
|
+
continue
|
|
317
|
+
if bid not in open_:
|
|
318
|
+
name = name_of(bid) or f"id {bid}"
|
|
319
|
+
raise unsupported(
|
|
320
|
+
f"bookmark {name!r} starts outside the moved paragraphs, "
|
|
321
|
+
"which a move cannot carry",
|
|
322
|
+
paragraph=address,
|
|
323
|
+
bookmark=name,
|
|
324
|
+
)
|
|
325
|
+
name = open_.pop(bid)
|
|
326
|
+
if name == GOBACK:
|
|
327
|
+
self.dropped.add(bid)
|
|
328
|
+
else:
|
|
329
|
+
self.carried[bid] = ""
|
|
330
|
+
self.names.append(name)
|
|
331
|
+
if open_:
|
|
332
|
+
name = next(iter(open_.values()))
|
|
333
|
+
raise unsupported(
|
|
334
|
+
f"bookmark {name!r} ends outside the moved paragraphs, which a move cannot carry",
|
|
335
|
+
paragraph=addresses[-1],
|
|
336
|
+
bookmark=name,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def owns(self, marker: ET.Element) -> bool:
|
|
340
|
+
bid = marker.get(ID, "")
|
|
341
|
+
return bid in self.dropped or bid in self.carried
|
|
342
|
+
|
|
343
|
+
def new_id(self, old: str) -> str:
|
|
344
|
+
if not self.carried[old]:
|
|
345
|
+
self.carried[old] = self.ctx.continuation_id()
|
|
346
|
+
return self.carried[old]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _clone_rpr(rpr: ET.Element, ctx: RevisionContext, *, keep_changes: bool) -> ET.Element:
|
|
350
|
+
"""A copy of a run's ``w:rPr``; a tracked format change either gets a new id or is dropped."""
|
|
351
|
+
clone = copy.deepcopy(rpr)
|
|
352
|
+
for parent in list(clone.iter()):
|
|
353
|
+
for child in list(parent):
|
|
354
|
+
if child.tag == W + "rPrChange" and not keep_changes:
|
|
355
|
+
parent.remove(child)
|
|
356
|
+
for elem in clone.iter():
|
|
357
|
+
if elem.get(ID) is not None:
|
|
358
|
+
elem.set(ID, ctx.continuation_id())
|
|
359
|
+
return clone
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def clone_run(run: ET.Element, ctx: RevisionContext) -> ET.Element:
|
|
363
|
+
"""A copy of ``run`` for new content: formatting kept, tracked format changes dropped."""
|
|
364
|
+
new = ET.Element(run.tag, dict(run.attrib))
|
|
365
|
+
for child in run:
|
|
366
|
+
if child.tag == RPR:
|
|
367
|
+
new.append(_clone_rpr(child, ctx, keep_changes=False))
|
|
368
|
+
else:
|
|
369
|
+
new.append(copy.deepcopy(child))
|
|
370
|
+
return new
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def text_children(text: str) -> list[ET.Element]:
|
|
374
|
+
"""``w:t``/``w:tab``/``w:br`` children giving exactly ``text`` as visible text."""
|
|
375
|
+
out: list[ET.Element] = []
|
|
376
|
+
for part in re.split(r"([\t\n])", text):
|
|
377
|
+
if part == "\t":
|
|
378
|
+
out.append(ET.Element(W + "tab"))
|
|
379
|
+
elif part == "\n":
|
|
380
|
+
out.append(ET.Element(BR))
|
|
381
|
+
elif part:
|
|
382
|
+
out.append(_text_element(T, part))
|
|
383
|
+
return out
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# -- paragraph marks --------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def mark_state(p: ET.Element) -> set[tuple[str, str]]:
|
|
390
|
+
"""``(kind, id)`` of the revisions on the paragraph mark (``ins``, ``del``…)."""
|
|
391
|
+
rpr = _mark_rpr(p)
|
|
392
|
+
if rpr is None:
|
|
393
|
+
return set()
|
|
394
|
+
return {(c.tag.removeprefix(W), c.get(ID, "")) for c in _elements(rpr) if c.tag in _MARK_TAGS}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _mark_rpr(p: ET.Element) -> ET.Element | None:
|
|
398
|
+
ppr = p.find(PPR)
|
|
399
|
+
return ppr.find(RPR) if ppr is not None else None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def has_section_break(p: ET.Element) -> bool:
|
|
403
|
+
ppr = p.find(PPR)
|
|
404
|
+
return ppr is not None and ppr.find(W + "sectPr") is not None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def set_mark(p: ET.Element, kind: str, ctx: RevisionContext) -> None:
|
|
408
|
+
"""Track the paragraph mark as inserted (``ins``) or deleted (``del``)."""
|
|
409
|
+
if has_section_break(p):
|
|
410
|
+
raise unsupported("the paragraph ends a section: its mark cannot be tracked")
|
|
411
|
+
ppr = p.find(PPR)
|
|
412
|
+
if ppr is None:
|
|
413
|
+
ppr = ET.Element(PPR)
|
|
414
|
+
p.insert(0, ppr)
|
|
415
|
+
rpr = ppr.find(RPR)
|
|
416
|
+
if rpr is None:
|
|
417
|
+
rpr = ET.Element(RPR)
|
|
418
|
+
later = [i for i, c in enumerate(ppr) if c.tag in (W + "sectPr", W + "pPrChange")]
|
|
419
|
+
ppr.insert(later[0] if later else len(ppr), rpr)
|
|
420
|
+
if any(c.tag == W + kind for c in rpr):
|
|
421
|
+
if kind == "ins":
|
|
422
|
+
raise unsupported("the paragraph mark is already someone else's insertion")
|
|
423
|
+
return # already deleted by someone else
|
|
424
|
+
where = 0
|
|
425
|
+
if kind == "del" and len(rpr) and rpr[0].tag == INS:
|
|
426
|
+
where = 1 # CT_ParaRPr orders ins before del
|
|
427
|
+
rpr.insert(where, ctx.revision(kind))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def track_paragraph_format(
|
|
431
|
+
p: ET.Element, ctx: RevisionContext, props: dict[str, object], where: dict[str, object]
|
|
432
|
+
) -> bool:
|
|
433
|
+
"""Change a paragraph's properties as a ``w:pPrChange`` holding the previous ones.
|
|
434
|
+
|
|
435
|
+
The change is the last child of ``w:pPr`` and its ``w:pPr`` the previous properties
|
|
436
|
+
(without the mark's ``w:rPr``, ``w:sectPr`` and any earlier change). Returns False if
|
|
437
|
+
nothing would change. A paragraph with an unresolved tracked property change is refused.
|
|
438
|
+
"""
|
|
439
|
+
ppr = p.find(PPR)
|
|
440
|
+
if ppr is not None and ppr.find(W + "pPrChange") is not None:
|
|
441
|
+
raise unsupported("the paragraph has an unresolved tracked property change", **where)
|
|
442
|
+
work = copy.deepcopy(ppr) if ppr is not None else ET.Element(PPR)
|
|
443
|
+
previous = ET.Element(PPR)
|
|
444
|
+
previous.extend(
|
|
445
|
+
copy.deepcopy(c) for c in work if c.tag not in (RPR, W + "sectPr", W + "pPrChange")
|
|
446
|
+
)
|
|
447
|
+
if not apply_ppr(work, props):
|
|
448
|
+
return False
|
|
449
|
+
change = ctx.revision("pPrChange")
|
|
450
|
+
change.append(previous)
|
|
451
|
+
work.append(change)
|
|
452
|
+
if ppr is not None:
|
|
453
|
+
p.remove(ppr)
|
|
454
|
+
p.insert(0, work)
|
|
455
|
+
return True
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def new_paragraph(
|
|
459
|
+
reference: ET.Element | None, ctx: RevisionContext, *, inserted_mark: bool
|
|
460
|
+
) -> tuple[ET.Element, ET.Element | None]:
|
|
461
|
+
"""An empty paragraph with the reference paragraph's properties.
|
|
462
|
+
|
|
463
|
+
Returns the ``w:p`` and the run properties its text should use (the
|
|
464
|
+
reference paragraph's mark properties). Section breaks, tracked property
|
|
465
|
+
changes and the reference's revision marks are not copied.
|
|
466
|
+
"""
|
|
467
|
+
p = ET.Element(P)
|
|
468
|
+
ppr = ET.Element(PPR)
|
|
469
|
+
src = reference.find(PPR) if reference is not None else None
|
|
470
|
+
if src is not None:
|
|
471
|
+
ppr = copy.deepcopy(src)
|
|
472
|
+
for child in list(ppr):
|
|
473
|
+
if child.tag in (W + "sectPr", W + "pPrChange"):
|
|
474
|
+
ppr.remove(child)
|
|
475
|
+
rpr = ppr.find(RPR)
|
|
476
|
+
if rpr is None:
|
|
477
|
+
rpr = ET.Element(RPR)
|
|
478
|
+
ppr.append(rpr)
|
|
479
|
+
for child in list(rpr):
|
|
480
|
+
if child.tag in _MARK_TAGS:
|
|
481
|
+
rpr.remove(child)
|
|
482
|
+
run_props: ET.Element | None = None
|
|
483
|
+
if len(rpr):
|
|
484
|
+
run_props = _clone_rpr(rpr, ctx, keep_changes=False)
|
|
485
|
+
if inserted_mark:
|
|
486
|
+
rpr.insert(0, ctx.revision("ins"))
|
|
487
|
+
elif not len(rpr):
|
|
488
|
+
ppr.remove(rpr)
|
|
489
|
+
if len(ppr):
|
|
490
|
+
p.append(ppr)
|
|
491
|
+
return p, run_props
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def inserted_content(
|
|
495
|
+
ctx: RevisionContext,
|
|
496
|
+
*,
|
|
497
|
+
text: str | None = None,
|
|
498
|
+
run_props: ET.Element | None = None,
|
|
499
|
+
runs: list[ET.Element] | None = None,
|
|
500
|
+
) -> ET.Element | None:
|
|
501
|
+
"""A ``w:ins`` holding a new run for ``text`` or the given ``runs``; None if both are empty."""
|
|
502
|
+
if runs is None:
|
|
503
|
+
if not text:
|
|
504
|
+
return None
|
|
505
|
+
run = ET.Element(R)
|
|
506
|
+
if run_props is not None:
|
|
507
|
+
run.append(copy.deepcopy(run_props))
|
|
508
|
+
run.extend(text_children(text))
|
|
509
|
+
runs = [run]
|
|
510
|
+
if not runs:
|
|
511
|
+
return None
|
|
512
|
+
ins = ctx.revision("ins")
|
|
513
|
+
ins.extend(runs)
|
|
514
|
+
return ins
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# -- the paragraph editor -----------------------------------------------------
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class ParagraphEditor:
|
|
521
|
+
"""Edits one paragraph element in place as tracked changes."""
|
|
522
|
+
|
|
523
|
+
def __init__(self, p: ET.Element, para: Paragraph, ctx: RevisionContext) -> None:
|
|
524
|
+
self.p = p
|
|
525
|
+
self.para = para
|
|
526
|
+
self.ctx = ctx
|
|
527
|
+
|
|
528
|
+
# -- locating ----------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def _where(self, what: str) -> dict[str, object]:
|
|
531
|
+
return {"paragraph": str(self.para.address), "where": what}
|
|
532
|
+
|
|
533
|
+
def _site(self, piece: Piece) -> tuple[ET.Element, ET.Element, int]:
|
|
534
|
+
"""``(parent, run, child index)`` of a piece in an editable place, else a refusal."""
|
|
535
|
+
if piece.in_field:
|
|
536
|
+
raise unsupported("the range covers text inside a field", **self._where("field"))
|
|
537
|
+
nodes = [self.p]
|
|
538
|
+
try:
|
|
539
|
+
for i in piece.path:
|
|
540
|
+
nodes.append(nodes[-1][i])
|
|
541
|
+
except IndexError as exc:
|
|
542
|
+
raise DocControlError(
|
|
543
|
+
ErrorCode.INTERNAL, f"{self.para.address}: index does not match the paragraph"
|
|
544
|
+
) from exc
|
|
545
|
+
inner, run, leaf = nodes[1:-2], nodes[-2], nodes[-1]
|
|
546
|
+
if run.tag != R:
|
|
547
|
+
raise unsupported("the range covers text outside a run", **self._where("structure"))
|
|
548
|
+
if len(inner) > 1 or (inner and inner[0].tag != INS):
|
|
549
|
+
kind = inner[0].tag.removeprefix(W) if inner and isinstance(inner[0].tag, str) else "?"
|
|
550
|
+
raise unsupported(
|
|
551
|
+
f"the range covers text inside a {kind} element, which v1 does not edit",
|
|
552
|
+
**self._where(kind),
|
|
553
|
+
)
|
|
554
|
+
kind = _run_kind(run)
|
|
555
|
+
if kind != "text":
|
|
556
|
+
raise unsupported(
|
|
557
|
+
"the range covers a run with a drawing, reference or other structure",
|
|
558
|
+
**self._where("run"),
|
|
559
|
+
)
|
|
560
|
+
parent = inner[0] if inner else self.p
|
|
561
|
+
# An edit to the right of this one may already have cut the text short.
|
|
562
|
+
seen = "".join(leaf.itertext()) if leaf.tag == T else None
|
|
563
|
+
if seen is not None and (
|
|
564
|
+
len(leaf) or not self.para.text[piece.start : piece.end].startswith(seen)
|
|
565
|
+
):
|
|
566
|
+
raise DocControlError(
|
|
567
|
+
ErrorCode.INTERNAL, f"{self.para.address}: index does not match the text"
|
|
568
|
+
)
|
|
569
|
+
return parent, run, piece.path[-1]
|
|
570
|
+
|
|
571
|
+
def _require_plain(self) -> None:
|
|
572
|
+
"""Refuse a paragraph holding anything but plain runs, markers and revisions."""
|
|
573
|
+
for _, node in _flat(self.p):
|
|
574
|
+
if _item_kind(node) == "other":
|
|
575
|
+
raise unsupported(
|
|
576
|
+
"the paragraph holds a field, link, drawing, reference or other structure",
|
|
577
|
+
**self._where(node.tag.removeprefix(W) if isinstance(node.tag, str) else "?"),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
# -- cutting runs ----------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
def _cut(
|
|
583
|
+
self, parent: ET.Element, run: ET.Element, k: int, offset: int
|
|
584
|
+
) -> tuple[ET.Element | None, ET.Element | None]:
|
|
585
|
+
"""Cut ``run`` before character ``offset`` of its child ``k``.
|
|
586
|
+
|
|
587
|
+
Returns ``(left, right)``: the run ending at the cut and the run
|
|
588
|
+
starting at it. One is ``None`` when the cut is at the run's edge, and
|
|
589
|
+
then the other is ``run`` itself. The left half keeps the original
|
|
590
|
+
element, so paths that point at or before the cut stay valid.
|
|
591
|
+
"""
|
|
592
|
+
child = run[k]
|
|
593
|
+
b = k
|
|
594
|
+
if child.tag == T:
|
|
595
|
+
text = child.text or ""
|
|
596
|
+
if offset >= len(text):
|
|
597
|
+
b = k + 1
|
|
598
|
+
elif offset > 0:
|
|
599
|
+
tail = ET.Element(T, dict(child.attrib))
|
|
600
|
+
tail.text = text[offset:]
|
|
601
|
+
child.text = text[:offset]
|
|
602
|
+
_set_space(child)
|
|
603
|
+
_set_space(tail)
|
|
604
|
+
run.insert(k + 1, tail)
|
|
605
|
+
b = k + 1
|
|
606
|
+
elif offset > 0:
|
|
607
|
+
b = k + 1
|
|
608
|
+
kids = list(run)
|
|
609
|
+
before = [c for c in kids[:b] if isinstance(c.tag, str) and c.tag != RPR]
|
|
610
|
+
after = [c for c in kids[b:] if isinstance(c.tag, str)]
|
|
611
|
+
if not before:
|
|
612
|
+
return None, run
|
|
613
|
+
if not after:
|
|
614
|
+
return run, None
|
|
615
|
+
right = ET.Element(run.tag, dict(run.attrib))
|
|
616
|
+
rpr = run.find(RPR)
|
|
617
|
+
if rpr is not None:
|
|
618
|
+
right.append(_clone_rpr(rpr, self.ctx, keep_changes=True))
|
|
619
|
+
for c in kids[b:]:
|
|
620
|
+
run.remove(c)
|
|
621
|
+
right.append(c)
|
|
622
|
+
parent.insert(list(parent).index(run) + 1, right)
|
|
623
|
+
return run, right
|
|
624
|
+
|
|
625
|
+
def _wrapper_copy(self, wrapper: ET.Element) -> ET.Element:
|
|
626
|
+
"""An empty ``w:ins`` like ``wrapper`` (same author and date) with a fresh id."""
|
|
627
|
+
new = ET.Element(wrapper.tag, dict(wrapper.attrib))
|
|
628
|
+
new.set(ID, self.ctx.continuation_id())
|
|
629
|
+
return new
|
|
630
|
+
|
|
631
|
+
def _insert_after(
|
|
632
|
+
self, parent: ET.Element, node: ET.Element, new: ET.Element | Sequence[ET.Element]
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Put ``new`` right after ``node``, outside any ``w:ins`` ``parent`` is."""
|
|
635
|
+
news = [new] if isinstance(new, ET.Element) else list(new)
|
|
636
|
+
if parent is self.p:
|
|
637
|
+
at = list(parent).index(node) + 1
|
|
638
|
+
for j, item in enumerate(news):
|
|
639
|
+
parent.insert(at + j, item)
|
|
640
|
+
return
|
|
641
|
+
kids = list(parent)
|
|
642
|
+
following = kids[kids.index(node) + 1 :]
|
|
643
|
+
at = list(self.p).index(parent) + 1
|
|
644
|
+
if following:
|
|
645
|
+
rest = self._wrapper_copy(parent)
|
|
646
|
+
for c in following:
|
|
647
|
+
parent.remove(c)
|
|
648
|
+
rest.append(c)
|
|
649
|
+
self.p.insert(at, rest)
|
|
650
|
+
for j, item in enumerate(news):
|
|
651
|
+
self.p.insert(at + j, item)
|
|
652
|
+
|
|
653
|
+
def _insert_before(
|
|
654
|
+
self, parent: ET.Element, node: ET.Element, new: ET.Element | Sequence[ET.Element]
|
|
655
|
+
) -> None:
|
|
656
|
+
"""Put ``new`` right before ``node``, outside any ``w:ins`` ``parent`` is."""
|
|
657
|
+
news = [new] if isinstance(new, ET.Element) else list(new)
|
|
658
|
+
if parent is self.p:
|
|
659
|
+
at = list(parent).index(node)
|
|
660
|
+
for j, item in enumerate(news):
|
|
661
|
+
parent.insert(at + j, item)
|
|
662
|
+
return
|
|
663
|
+
kids = list(parent)
|
|
664
|
+
at = list(self.p).index(parent)
|
|
665
|
+
if any(isinstance(c.tag, str) for c in kids[: kids.index(node)]):
|
|
666
|
+
rest = self._wrapper_copy(parent)
|
|
667
|
+
for c in kids[kids.index(node) :]:
|
|
668
|
+
parent.remove(c)
|
|
669
|
+
rest.append(c)
|
|
670
|
+
self.p.insert(at + 1, rest)
|
|
671
|
+
at += 1
|
|
672
|
+
for j, item in enumerate(news):
|
|
673
|
+
self.p.insert(at + j, item)
|
|
674
|
+
|
|
675
|
+
# -- the edits ---------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
def replace(self, start: int, end: int, text: str | None) -> None:
|
|
678
|
+
"""Track the deletion of visible characters ``[start, end)`` and insert ``text`` there.
|
|
679
|
+
|
|
680
|
+
``text`` of ``None`` or ``""`` only deletes. With ``start == end`` the
|
|
681
|
+
text is inserted at that offset. The caller has checked the offsets.
|
|
682
|
+
"""
|
|
683
|
+
if start == end:
|
|
684
|
+
if text:
|
|
685
|
+
self._insert_at(start, text)
|
|
686
|
+
return
|
|
687
|
+
pieces = self.para.pieces_in(start, end)
|
|
688
|
+
sites = [self._site(piece) for piece in pieces]
|
|
689
|
+
first_piece, last_piece = pieces[0], pieces[-1]
|
|
690
|
+
first_parent, first_run, first_k = sites[0]
|
|
691
|
+
last_parent, last_run, last_k = sites[-1]
|
|
692
|
+
rpr = first_run.find(RPR)
|
|
693
|
+
run_props = _clone_rpr(rpr, self.ctx, keep_changes=False) if rpr is not None else None
|
|
694
|
+
|
|
695
|
+
left, _ = self._cut(last_parent, last_run, last_k, end - last_piece.start)
|
|
696
|
+
assert left is not None
|
|
697
|
+
last_run = left
|
|
698
|
+
_, right = self._cut(first_parent, first_run, first_k, start - first_piece.start)
|
|
699
|
+
if right is not None:
|
|
700
|
+
first_run = right
|
|
701
|
+
if last_run is sites[0][1]: # the range ended in the run that was just split
|
|
702
|
+
last_run = right
|
|
703
|
+
|
|
704
|
+
items = _flat(self.p)
|
|
705
|
+
order = {id(node): i for i, (_, node) in enumerate(items)}
|
|
706
|
+
span = items[order[id(first_run)] : order[id(last_run)] + 1]
|
|
707
|
+
groups: list[list[tuple[ET.Element, ET.Element]]] = []
|
|
708
|
+
broken = True
|
|
709
|
+
for parent, node in span:
|
|
710
|
+
kind = _item_kind(node)
|
|
711
|
+
if kind == "other":
|
|
712
|
+
raise unsupported(
|
|
713
|
+
"the range covers a field, link, drawing, reference or other structure",
|
|
714
|
+
**self._where(node.tag.removeprefix(W) if isinstance(node.tag, str) else "?"),
|
|
715
|
+
)
|
|
716
|
+
if kind == "text":
|
|
717
|
+
if broken or groups[-1][-1][0] is not parent:
|
|
718
|
+
groups.append([])
|
|
719
|
+
groups[-1].append((parent, node))
|
|
720
|
+
broken = False
|
|
721
|
+
else:
|
|
722
|
+
broken = True
|
|
723
|
+
wrapped = self._wrap(groups)
|
|
724
|
+
last_del, last_parent_of_del = wrapped if wrapped else (None, self.p)
|
|
725
|
+
if text:
|
|
726
|
+
assert last_del is not None
|
|
727
|
+
ins = inserted_content(self.ctx, text=text, run_props=run_props)
|
|
728
|
+
assert ins is not None
|
|
729
|
+
self._insert_after(last_parent_of_del, last_del, ins)
|
|
730
|
+
|
|
731
|
+
def _wrap(
|
|
732
|
+
self, groups: list[list[tuple[ET.Element, ET.Element]]]
|
|
733
|
+
) -> tuple[ET.Element, ET.Element] | None:
|
|
734
|
+
"""Move each group of runs into a new ``w:del``; returns the last ``(del, parent)``."""
|
|
735
|
+
last: tuple[ET.Element, ET.Element] | None = None
|
|
736
|
+
for group in groups:
|
|
737
|
+
parent = group[0][0]
|
|
738
|
+
runs = [node for _, node in group]
|
|
739
|
+
at = list(parent).index(runs[0])
|
|
740
|
+
deletion = self.ctx.revision("del")
|
|
741
|
+
for run in runs:
|
|
742
|
+
parent.remove(run)
|
|
743
|
+
for child in run:
|
|
744
|
+
if child.tag == T:
|
|
745
|
+
child.tag = DELTEXT
|
|
746
|
+
deletion.append(run)
|
|
747
|
+
parent.insert(at, deletion)
|
|
748
|
+
last = (deletion, parent)
|
|
749
|
+
return last
|
|
750
|
+
|
|
751
|
+
def _insert_at(self, offset: int, text: str) -> None:
|
|
752
|
+
"""Insert ``text`` at visible ``offset`` (no characters deleted)."""
|
|
753
|
+
after = self.para.pieces_in(offset, offset + 1)
|
|
754
|
+
before = self.para.pieces_in(offset - 1, offset) if offset > 0 else []
|
|
755
|
+
run_props: ET.Element | None = None
|
|
756
|
+
if after and not after[0].in_field and self._editable(after[0]):
|
|
757
|
+
parent, run, k = self._site(after[0])
|
|
758
|
+
run_props = self._props_of(run)
|
|
759
|
+
_, right = self._cut(parent, run, k, offset - after[0].start)
|
|
760
|
+
target = right if right is not None else run
|
|
761
|
+
ins = inserted_content(self.ctx, text=text, run_props=run_props)
|
|
762
|
+
assert ins is not None
|
|
763
|
+
self._insert_before(parent, target, ins)
|
|
764
|
+
elif before and not before[0].in_field and self._editable(before[0]):
|
|
765
|
+
parent, run, k = self._site(before[0])
|
|
766
|
+
run_props = self._props_of(run)
|
|
767
|
+
left, _ = self._cut(parent, run, k, offset - before[0].start)
|
|
768
|
+
target = left if left is not None else run
|
|
769
|
+
ins = inserted_content(self.ctx, text=text, run_props=run_props)
|
|
770
|
+
assert ins is not None
|
|
771
|
+
self._insert_after(parent, target, ins)
|
|
772
|
+
elif not self.para.pieces:
|
|
773
|
+
self._require_plain()
|
|
774
|
+
ins = inserted_content(self.ctx, text=text, run_props=self._mark_props())
|
|
775
|
+
assert ins is not None
|
|
776
|
+
self.p.append(ins)
|
|
777
|
+
else:
|
|
778
|
+
raise unsupported(
|
|
779
|
+
"the insertion point is next to a field, link or other structure",
|
|
780
|
+
**self._where("insertion point"),
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
def place(self, offset: int, nodes: Sequence[ET.Element]) -> None:
|
|
784
|
+
"""Put zero-width ``nodes`` (comment markers) at visible ``offset``; no text changes.
|
|
785
|
+
|
|
786
|
+
Offset ``0`` and the end of the text go to the paragraph's edges, so a
|
|
787
|
+
whole-paragraph anchor works next to a field or link. Elsewhere the run at
|
|
788
|
+
the offset is cut and the nodes land between the halves, outside any
|
|
789
|
+
``w:ins``. Several placements in one paragraph must be made from the
|
|
790
|
+
highest offset down: a placement shifts what follows it. Raises
|
|
791
|
+
``UNSUPPORTED_CONTENT`` inside a field, link or other structure.
|
|
792
|
+
"""
|
|
793
|
+
if offset <= 0:
|
|
794
|
+
at = 1 if len(self.p) and self.p[0].tag == PPR else 0
|
|
795
|
+
for j, item in enumerate(nodes):
|
|
796
|
+
self.p.insert(at + j, item)
|
|
797
|
+
return
|
|
798
|
+
if offset >= len(self.para.text):
|
|
799
|
+
self.p.extend(nodes)
|
|
800
|
+
return
|
|
801
|
+
after = self.para.pieces_in(offset, offset + 1)
|
|
802
|
+
before = self.para.pieces_in(offset - 1, offset)
|
|
803
|
+
if after and self._editable(after[0]):
|
|
804
|
+
parent, run, k = self._site(after[0])
|
|
805
|
+
_, right = self._cut(parent, run, k, offset - after[0].start)
|
|
806
|
+
self._insert_before(parent, right if right is not None else run, nodes)
|
|
807
|
+
elif before and self._editable(before[0]):
|
|
808
|
+
parent, run, k = self._site(before[0])
|
|
809
|
+
left, _ = self._cut(parent, run, k, offset - before[0].start)
|
|
810
|
+
self._insert_after(parent, left if left is not None else run, nodes)
|
|
811
|
+
else:
|
|
812
|
+
raise unsupported(
|
|
813
|
+
"the anchor point is inside or next to a field, link or other structure",
|
|
814
|
+
**self._where("anchor point"),
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
def _editable(self, piece: Piece) -> bool:
|
|
818
|
+
try:
|
|
819
|
+
self._site(piece)
|
|
820
|
+
except DocControlError as exc:
|
|
821
|
+
if exc.code is ErrorCode.UNSUPPORTED_CONTENT:
|
|
822
|
+
return False
|
|
823
|
+
raise
|
|
824
|
+
return True
|
|
825
|
+
|
|
826
|
+
def _props_of(self, run: ET.Element) -> ET.Element | None:
|
|
827
|
+
rpr = run.find(RPR)
|
|
828
|
+
return _clone_rpr(rpr, self.ctx, keep_changes=False) if rpr is not None else None
|
|
829
|
+
|
|
830
|
+
def _mark_props(self) -> ET.Element | None:
|
|
831
|
+
rpr = _mark_rpr(self.p)
|
|
832
|
+
if rpr is None:
|
|
833
|
+
return None
|
|
834
|
+
props = _clone_rpr(rpr, self.ctx, keep_changes=False)
|
|
835
|
+
for child in list(props):
|
|
836
|
+
if child.tag in _MARK_TAGS:
|
|
837
|
+
props.remove(child)
|
|
838
|
+
return props if len(props) else None
|
|
839
|
+
|
|
840
|
+
def delete_content(self) -> None:
|
|
841
|
+
"""Track the deletion of every run in the paragraph, hidden ones included."""
|
|
842
|
+
self._require_plain()
|
|
843
|
+
groups: list[list[tuple[ET.Element, ET.Element]]] = []
|
|
844
|
+
broken = True
|
|
845
|
+
for parent, node in _flat(self.p):
|
|
846
|
+
kind = _item_kind(node)
|
|
847
|
+
if kind == "hidden" and W + "commentReference" in _tokens(node):
|
|
848
|
+
raise unsupported(
|
|
849
|
+
"a hidden run holds a comment reference, which cannot be deleted here",
|
|
850
|
+
**self._where("comment reference"),
|
|
851
|
+
)
|
|
852
|
+
if kind in ("text", "hidden"):
|
|
853
|
+
if broken or groups[-1][-1][0] is not parent:
|
|
854
|
+
groups.append([])
|
|
855
|
+
groups[-1].append((parent, node))
|
|
856
|
+
broken = False
|
|
857
|
+
else:
|
|
858
|
+
broken = True
|
|
859
|
+
self._wrap(groups)
|
|
860
|
+
|
|
861
|
+
def detach_bookmarks(self, scope: BookmarkScope) -> None:
|
|
862
|
+
"""Remove the markers a move carries away (or drops) from this deleted original."""
|
|
863
|
+
for parent, node in _flat(self.p):
|
|
864
|
+
if node.tag in (BOOKMARK_START, BOOKMARK_END) and scope.owns(node):
|
|
865
|
+
parent.remove(node)
|
|
866
|
+
|
|
867
|
+
def copy_runs(self, scope: BookmarkScope | None = None) -> list[ET.Element]:
|
|
868
|
+
"""Copies of the paragraph's runs (for a move), made before it is edited.
|
|
869
|
+
|
|
870
|
+
Bookmark markers come along (same name, fresh id) when ``scope`` says
|
|
871
|
+
the move carries them. A paragraph with a comment reference or a comment
|
|
872
|
+
range marker is refused: copying them would duplicate their ids, and
|
|
873
|
+
leaving them behind would orphan the new text.
|
|
874
|
+
"""
|
|
875
|
+
self._require_plain()
|
|
876
|
+
out: list[ET.Element] = []
|
|
877
|
+
for _, node in _flat(self.p):
|
|
878
|
+
if node.tag in (BOOKMARK_START, BOOKMARK_END):
|
|
879
|
+
if scope is None or not scope.owns(node):
|
|
880
|
+
raise unsupported(
|
|
881
|
+
"the paragraph holds a bookmark, which this move cannot carry",
|
|
882
|
+
**self._where(node.get(W + "name") or "bookmark"),
|
|
883
|
+
)
|
|
884
|
+
bid = node.get(ID, "")
|
|
885
|
+
if bid not in scope.dropped:
|
|
886
|
+
clone = copy.deepcopy(node)
|
|
887
|
+
clone.set(ID, scope.new_id(bid))
|
|
888
|
+
out.append(clone)
|
|
889
|
+
continue
|
|
890
|
+
if node.tag in _INERT_ELEMENTS and node.tag != W + "proofErr":
|
|
891
|
+
raise unsupported(
|
|
892
|
+
"the paragraph holds a comment anchor, which a move cannot carry",
|
|
893
|
+
**self._where(node.tag.removeprefix(W)),
|
|
894
|
+
)
|
|
895
|
+
if node.tag != R:
|
|
896
|
+
continue
|
|
897
|
+
kind = _run_kind(node)
|
|
898
|
+
if kind == "inert" and W + "commentReference" in _tokens(node):
|
|
899
|
+
raise unsupported(
|
|
900
|
+
"the paragraph holds a comment reference, which a move cannot carry",
|
|
901
|
+
**self._where("comment reference"),
|
|
902
|
+
)
|
|
903
|
+
if kind in ("text", "hidden"):
|
|
904
|
+
if W + "commentReference" in _tokens(node):
|
|
905
|
+
raise unsupported(
|
|
906
|
+
"the paragraph holds a comment reference, which a move cannot carry",
|
|
907
|
+
**self._where("comment reference"),
|
|
908
|
+
)
|
|
909
|
+
out.append(clone_run(node, self.ctx))
|
|
910
|
+
return out
|
|
911
|
+
|
|
912
|
+
def mark(self, kind: str) -> None:
|
|
913
|
+
set_mark(self.p, kind, self.ctx)
|
|
914
|
+
|
|
915
|
+
def format_paragraph(self, props: dict[str, object]) -> bool:
|
|
916
|
+
"""Change paragraph properties as a tracked ``w:pPrChange``; False if nothing changes."""
|
|
917
|
+
return track_paragraph_format(self.p, self.ctx, props, self._where("paragraph properties"))
|
|
918
|
+
|
|
919
|
+
def format_range(self, start: int, end: int, props: dict[str, object]) -> bool:
|
|
920
|
+
"""Change run properties of visible characters ``[start, end)`` as ``w:rPrChange``.
|
|
921
|
+
|
|
922
|
+
Runs are split at the ends of the range. Returns False if every run already has
|
|
923
|
+
the properties. The caller has checked the offsets.
|
|
924
|
+
"""
|
|
925
|
+
pieces = self.para.pieces_in(start, end)
|
|
926
|
+
if not pieces:
|
|
927
|
+
return False
|
|
928
|
+
sites = [self._site(piece) for piece in pieces]
|
|
929
|
+
first_piece, last_piece = pieces[0], pieces[-1]
|
|
930
|
+
first_parent, first_run, first_k = sites[0]
|
|
931
|
+
last_parent, last_run, last_k = sites[-1]
|
|
932
|
+
left, _ = self._cut(last_parent, last_run, last_k, end - last_piece.start)
|
|
933
|
+
assert left is not None
|
|
934
|
+
last_run = left
|
|
935
|
+
_, right = self._cut(first_parent, first_run, first_k, start - first_piece.start)
|
|
936
|
+
if right is not None:
|
|
937
|
+
first_run = right
|
|
938
|
+
if last_run is sites[0][1]:
|
|
939
|
+
last_run = right
|
|
940
|
+
items = _flat(self.p)
|
|
941
|
+
order = {id(node): i for i, (_, node) in enumerate(items)}
|
|
942
|
+
changed = False
|
|
943
|
+
for _, node in items[order[id(first_run)] : order[id(last_run)] + 1]:
|
|
944
|
+
kind = _item_kind(node)
|
|
945
|
+
if kind == "other":
|
|
946
|
+
raise unsupported(
|
|
947
|
+
"the range covers a field, link, drawing, reference or other structure",
|
|
948
|
+
**self._where(node.tag.removeprefix(W) if isinstance(node.tag, str) else "?"),
|
|
949
|
+
)
|
|
950
|
+
if kind == "text":
|
|
951
|
+
changed |= self._format_run(node, props)
|
|
952
|
+
return changed
|
|
953
|
+
|
|
954
|
+
def _format_run(self, run: ET.Element, props: dict[str, object]) -> bool:
|
|
955
|
+
rpr = run.find(RPR)
|
|
956
|
+
if rpr is not None and rpr.find(W + "rPrChange") is not None:
|
|
957
|
+
raise unsupported(
|
|
958
|
+
"the range holds a run with an unresolved tracked format change",
|
|
959
|
+
**self._where("run"),
|
|
960
|
+
)
|
|
961
|
+
work = copy.deepcopy(rpr) if rpr is not None else ET.Element(RPR)
|
|
962
|
+
if not apply_rpr(work, props):
|
|
963
|
+
return False
|
|
964
|
+
change = self.ctx.revision("rPrChange")
|
|
965
|
+
change.append(copy.deepcopy(rpr) if rpr is not None else ET.Element(RPR))
|
|
966
|
+
work.append(change)
|
|
967
|
+
if rpr is not None:
|
|
968
|
+
run.remove(rpr)
|
|
969
|
+
run.insert(0, work)
|
|
970
|
+
return True
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# -- simulating accept and reject (used to verify a write) ----------------------
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _restore_properties(props: ET.Element, change: ET.Element) -> None:
|
|
977
|
+
"""Reject a ``w:pPrChange`` / ``w:rPrChange``: the previous properties come back."""
|
|
978
|
+
keep = (RPR, W + "sectPr", W + "pPrChange") if props.tag == PPR else (W + "rPrChange",)
|
|
979
|
+
previous = change.find(PPR if props.tag == PPR else RPR)
|
|
980
|
+
kept = [c for c in props if c.tag in keep and c is not change]
|
|
981
|
+
for child in list(props):
|
|
982
|
+
props.remove(child)
|
|
983
|
+
if previous is not None:
|
|
984
|
+
props.extend(list(previous))
|
|
985
|
+
props.extend(kept)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def reject_revisions(p: ET.Element, ids: set[str]) -> None:
|
|
989
|
+
"""Reject, in place, the revisions of ``p`` whose id is in ``ids`` (marks excluded).
|
|
990
|
+
|
|
991
|
+
Inserted content is removed; a deletion is unwrapped and its ``w:delText``
|
|
992
|
+
becomes ``w:t`` again; a ``w:pPrChange`` / ``w:rPrChange`` puts back the properties it holds.
|
|
993
|
+
"""
|
|
994
|
+
|
|
995
|
+
for parent in list(p.iter()):
|
|
996
|
+
for child in list(parent):
|
|
997
|
+
if child.tag in (W + "pPrChange", W + "rPrChange") and child.get(ID) in ids:
|
|
998
|
+
_restore_properties(parent, child)
|
|
999
|
+
|
|
1000
|
+
def walk(node: ET.Element) -> None:
|
|
1001
|
+
for child in list(node):
|
|
1002
|
+
if not isinstance(child.tag, str):
|
|
1003
|
+
continue
|
|
1004
|
+
mine = child.get(ID) in ids and node.tag != RPR
|
|
1005
|
+
if child.tag == INS and mine:
|
|
1006
|
+
node.remove(child)
|
|
1007
|
+
elif child.tag == DEL and mine:
|
|
1008
|
+
at = list(node).index(child)
|
|
1009
|
+
node.remove(child)
|
|
1010
|
+
for offset, sub in enumerate(list(child)):
|
|
1011
|
+
for elem in sub.iter():
|
|
1012
|
+
if elem.tag == DELTEXT:
|
|
1013
|
+
elem.tag = T
|
|
1014
|
+
node.insert(at + offset, sub)
|
|
1015
|
+
walk_range(node, at, len(child))
|
|
1016
|
+
else:
|
|
1017
|
+
walk(child)
|
|
1018
|
+
|
|
1019
|
+
def walk_range(node: ET.Element, at: int, count: int) -> None:
|
|
1020
|
+
for sub in list(node)[at : at + count]:
|
|
1021
|
+
if isinstance(sub.tag, str):
|
|
1022
|
+
walk(sub)
|
|
1023
|
+
|
|
1024
|
+
walk(p)
|