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,1441 @@
|
|
|
1
|
+
"""Tracked operations: ``text.replace``, ``para.insert``, ``para.delete``, ``para.move``,
|
|
2
|
+
``style.apply``, ``format.apply``.
|
|
3
|
+
|
|
4
|
+
:func:`apply_edits` applies a list of edits to a closed ``.docx`` in one verified
|
|
5
|
+
write. Every address in the list refers to the document as it was read (all are
|
|
6
|
+
resolved before anything changes), every change is a tracked revision and
|
|
7
|
+
nothing outside the edited paragraphs changes.
|
|
8
|
+
|
|
9
|
+
The six edits
|
|
10
|
+
``text.replace`` ``at`` is a range (``p3#fp:10-15``, a whole paragraph
|
|
11
|
+
``p3#fp``, or across paragraphs ``p3#fp:10..p5#fp:2``) and ``text`` the new
|
|
12
|
+
text (``""`` deletes). A range across paragraphs deletes the tail of the
|
|
13
|
+
first, whole middle paragraphs and the head of the last, tracks the
|
|
14
|
+
deletion of the marks in between and puts the new text at the end of the
|
|
15
|
+
first paragraph: accepted, the paragraphs join (the last one's properties
|
|
16
|
+
win), rejected, they are all back. ``\\t`` and ``\\n`` in ``text`` become
|
|
17
|
+
``w:tab`` and ``w:br``, so the new visible text is exactly ``text``.
|
|
18
|
+
|
|
19
|
+
``para.insert`` adds a paragraph ``before`` or ``after`` a paragraph, with
|
|
20
|
+
that paragraph's properties. Normally the new paragraph has an inserted mark
|
|
21
|
+
(rejected, it merges into the paragraph after it and disappears). After the
|
|
22
|
+
last paragraph of a table cell, the body or a run of paragraphs before a
|
|
23
|
+
table, Word's own form is used: the *reference* paragraph's mark becomes the
|
|
24
|
+
inserted one and the new paragraph keeps the original mark.
|
|
25
|
+
|
|
26
|
+
``para.delete`` deletes one paragraph or a run (``p3#fp..p5#fp``): content
|
|
27
|
+
and mark are tracked deletions. The last paragraph of a cell or of the body
|
|
28
|
+
cannot be deleted (Word cannot remove that mark): delete its text instead.
|
|
29
|
+
|
|
30
|
+
``para.move`` moves a run of paragraphs ``after`` or ``before`` another one
|
|
31
|
+
as a tracked deletion of the originals plus a tracked insertion of copies
|
|
32
|
+
(spec: v1 has no ``w:moveFrom``/``w:moveTo``). Bookmarks that start and end
|
|
33
|
+
inside the moved paragraphs go with the text (same name, new id; ``_GoBack``
|
|
34
|
+
is dropped; rejecting the move removes them, so REF/PAGEREF fields, TOC entries and
|
|
35
|
+
links aimed at them are reported in ``warnings``); a bookmark reaching outside
|
|
36
|
+
them, and comment anchors, are refused. Moving to after the last paragraph of a container
|
|
37
|
+
uses Word's own form, as ``para.insert`` does (the target's mark becomes the inserted one).
|
|
38
|
+
|
|
39
|
+
``style.apply`` gives whole paragraphs (``p3#fp`` or ``p3#fp..p5#fp``) a paragraph
|
|
40
|
+
style, or a range of text a character style, by id or name. Paragraph properties are
|
|
41
|
+
a tracked ``w:pPrChange`` holding the previous properties (so the previous style);
|
|
42
|
+
run properties a ``w:rPrChange`` on each run of the range, split at its ends.
|
|
43
|
+
|
|
44
|
+
``format.apply`` changes direct formatting the same way. Run properties (``bold``,
|
|
45
|
+
``italic``, ``underline``, ``small_caps``, ``font``, ``size_pt``) apply to a paragraph,
|
|
46
|
+
a run of paragraphs or a range of text; paragraph properties (``align``,
|
|
47
|
+
``indent_left_cm``, ``indent_right_cm``, ``indent_first_line_cm``, ``spacing_before_pt``,
|
|
48
|
+
``spacing_after_pt``, ``line_spacing``) to whole paragraphs only. Setting what is already
|
|
49
|
+
set writes nothing; a paragraph or run that already holds an unresolved tracked property
|
|
50
|
+
change is refused (``UNSUPPORTED_CONTENT``). Rejecting the change gives the previous
|
|
51
|
+
properties back.
|
|
52
|
+
|
|
53
|
+
Offsets
|
|
54
|
+
Offsets are Python code points of the visible text. A write never splits a
|
|
55
|
+
base character from what attaches to it: a boundary in front of a combining
|
|
56
|
+
mark (Mn, Mc, Me), a zero width joiner or the character after one, an emoji
|
|
57
|
+
modifier, a tag character, or inside a pair of regional indicators raises
|
|
58
|
+
``ADDRESS_INVALID``.
|
|
59
|
+
|
|
60
|
+
Several edits in one call
|
|
61
|
+
Edits on different paragraphs are independent. Formatting edits on one paragraph
|
|
62
|
+
combine (different properties, disjoint ranges) and may sit beside text replacements
|
|
63
|
+
that neither overlap nor touch their range; the same property set twice, or overlapping
|
|
64
|
+
ranges, raise ``BATCH_CONFLICT``. Text replacements in the
|
|
65
|
+
same paragraph must not overlap or touch an insertion point. Two inserts at
|
|
66
|
+
the same place, or any other edit on a paragraph already edited, raise
|
|
67
|
+
``BATCH_CONFLICT``.
|
|
68
|
+
|
|
69
|
+
Authorship
|
|
70
|
+
``w:author`` is ``author`` (default ``Claude``) followed by ``(tag)`` when a
|
|
71
|
+
tag is given; ``w:date`` is the time of the call, ISO 8601 UTC.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
from __future__ import annotations
|
|
75
|
+
|
|
76
|
+
import itertools
|
|
77
|
+
import re
|
|
78
|
+
import unicodedata
|
|
79
|
+
import xml.etree.ElementTree as ET
|
|
80
|
+
from collections.abc import Callable, Collection, Mapping, Sequence
|
|
81
|
+
from dataclasses import dataclass, field, replace
|
|
82
|
+
from datetime import datetime
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
from typing import Any
|
|
85
|
+
|
|
86
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
87
|
+
from doc_control.model.address import parse_address
|
|
88
|
+
from doc_control.model.index import DocumentIndex, Paragraph, build_index
|
|
89
|
+
from doc_control.model.styles import StyleTable, load_styles
|
|
90
|
+
from doc_control.ooxml.formatting import ALIGNMENTS, PARAGRAPH_KEYS, RUN_KEYS
|
|
91
|
+
from doc_control.ooxml.revisions import (
|
|
92
|
+
DEFAULT_AUTHOR,
|
|
93
|
+
BookmarkScope,
|
|
94
|
+
ParagraphEditor,
|
|
95
|
+
RevisionContext,
|
|
96
|
+
format_author,
|
|
97
|
+
format_date,
|
|
98
|
+
inserted_content,
|
|
99
|
+
mark_state,
|
|
100
|
+
new_paragraph,
|
|
101
|
+
referenced_bookmarks,
|
|
102
|
+
reject_revisions,
|
|
103
|
+
used_ids,
|
|
104
|
+
)
|
|
105
|
+
from doc_control.ooxml.splice import W_NS, DocumentSpans
|
|
106
|
+
from doc_control.ooxml.zipio import Package
|
|
107
|
+
from doc_control.safety.history import has_live_text, record, to_text
|
|
108
|
+
from doc_control.safety.lock import check_changed, check_unlocked
|
|
109
|
+
from doc_control.safety.verify import DOCUMENT_PART, Footprint, write_verified
|
|
110
|
+
from doc_control.schema import validate
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"REQUEST_NAMES",
|
|
114
|
+
"ApplyFormat",
|
|
115
|
+
"ApplyStyle",
|
|
116
|
+
"DeleteParagraphs",
|
|
117
|
+
"Edit",
|
|
118
|
+
"InsertParagraph",
|
|
119
|
+
"MoveParagraphs",
|
|
120
|
+
"PreparedEdits",
|
|
121
|
+
"ReplaceText",
|
|
122
|
+
"TrackedResult",
|
|
123
|
+
"apply_edits",
|
|
124
|
+
"edit_from_dict",
|
|
125
|
+
"edits_as_request",
|
|
126
|
+
"log_changes",
|
|
127
|
+
"next_in_container",
|
|
128
|
+
"prepare_edits",
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
PREVIEW_LIMIT = 200
|
|
132
|
+
# Rewrites a paragraph element in place without changing its text (comment anchors).
|
|
133
|
+
Annotator = Callable[[ET.Element, Paragraph, RevisionContext], None]
|
|
134
|
+
_ZWJ = "\N{ZERO WIDTH JOINER}"
|
|
135
|
+
_INERT_LOCALS = frozenset(
|
|
136
|
+
name.encode()
|
|
137
|
+
for name in (
|
|
138
|
+
"bookmarkStart",
|
|
139
|
+
"bookmarkEnd",
|
|
140
|
+
"commentRangeStart",
|
|
141
|
+
"commentRangeEnd",
|
|
142
|
+
"permStart",
|
|
143
|
+
"permEnd",
|
|
144
|
+
"proofErr",
|
|
145
|
+
"moveFromRangeStart",
|
|
146
|
+
"moveFromRangeEnd",
|
|
147
|
+
"moveToRangeStart",
|
|
148
|
+
"moveToRangeEnd",
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
_SKIPPABLE = re.compile(rb"<!--.*?-->|<\?.*?\?>", re.S)
|
|
152
|
+
_TAG = re.compile(rb"""<(/?)([^\s/>!?]+)(?:"[^"]*"|'[^']*'|[^>"'])*>""")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# -- the edits and their schemas -------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class ReplaceText:
|
|
160
|
+
at: str
|
|
161
|
+
text: str
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class InsertParagraph:
|
|
166
|
+
text: str
|
|
167
|
+
after: str | None = None
|
|
168
|
+
before: str | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class DeleteParagraphs:
|
|
173
|
+
at: str
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class MoveParagraphs:
|
|
178
|
+
at: str
|
|
179
|
+
after: str | None = None
|
|
180
|
+
before: str | None = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class ApplyStyle:
|
|
185
|
+
at: str
|
|
186
|
+
style: str
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class ApplyFormat:
|
|
191
|
+
at: str
|
|
192
|
+
props: dict[str, Any]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
Edit = ReplaceText | InsertParagraph | DeleteParagraphs | MoveParagraphs | ApplyStyle | ApplyFormat
|
|
196
|
+
|
|
197
|
+
_STR: dict[str, Any] = {"type": "string", "minLength": 1}
|
|
198
|
+
OPERATION_SCHEMAS: dict[str, dict[str, Any]] = {
|
|
199
|
+
"text.replace": {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"properties": {
|
|
202
|
+
"op": {"enum": ["text.replace"]},
|
|
203
|
+
"at": {**_STR, "description": "Range p3#fp:10-15, p3#fp or p3#fp:10..p5#fp:2."},
|
|
204
|
+
"text": {"type": "string", "description": "New text; empty deletes the range."},
|
|
205
|
+
},
|
|
206
|
+
"required": ["op", "at", "text"],
|
|
207
|
+
"additionalProperties": False,
|
|
208
|
+
},
|
|
209
|
+
"para.insert": {
|
|
210
|
+
"type": "object",
|
|
211
|
+
"properties": {
|
|
212
|
+
"op": {"enum": ["para.insert"]},
|
|
213
|
+
"text": {"type": "string"},
|
|
214
|
+
"after": {**_STR, "description": "Insert after this paragraph (or before)."},
|
|
215
|
+
"before": {**_STR, "description": "Insert before this paragraph (or after)."},
|
|
216
|
+
},
|
|
217
|
+
"required": ["op", "text"],
|
|
218
|
+
"additionalProperties": False,
|
|
219
|
+
},
|
|
220
|
+
"para.delete": {
|
|
221
|
+
"type": "object",
|
|
222
|
+
"properties": {
|
|
223
|
+
"op": {"enum": ["para.delete"]},
|
|
224
|
+
"at": {**_STR, "description": "Paragraph p3#fp or run p3#fp..p5#fp."},
|
|
225
|
+
},
|
|
226
|
+
"required": ["op", "at"],
|
|
227
|
+
"additionalProperties": False,
|
|
228
|
+
},
|
|
229
|
+
"para.move": {
|
|
230
|
+
"type": "object",
|
|
231
|
+
"properties": {
|
|
232
|
+
"op": {"enum": ["para.move"]},
|
|
233
|
+
"at": {**_STR, "description": "Paragraph p3#fp or run p3#fp..p5#fp."},
|
|
234
|
+
"after": {**_STR, "description": "Move after this paragraph (or before)."},
|
|
235
|
+
"before": {**_STR, "description": "Move before this paragraph (or after)."},
|
|
236
|
+
},
|
|
237
|
+
"required": ["op", "at"],
|
|
238
|
+
"additionalProperties": False,
|
|
239
|
+
},
|
|
240
|
+
"style.apply": {
|
|
241
|
+
"type": "object",
|
|
242
|
+
"properties": {
|
|
243
|
+
"op": {"enum": ["style.apply"]},
|
|
244
|
+
"at": {
|
|
245
|
+
**_STR,
|
|
246
|
+
"description": "Paragraph p3#fp or run p3#fp..p5#fp (paragraph style), or a "
|
|
247
|
+
"range p3#fp:10-15 (character style).",
|
|
248
|
+
},
|
|
249
|
+
"style": {**_STR, "description": "Style id or name (see styles.list)."},
|
|
250
|
+
},
|
|
251
|
+
"required": ["op", "at", "style"],
|
|
252
|
+
"additionalProperties": False,
|
|
253
|
+
},
|
|
254
|
+
"format.apply": {
|
|
255
|
+
"type": "object",
|
|
256
|
+
"properties": {
|
|
257
|
+
"op": {"enum": ["format.apply"]},
|
|
258
|
+
"at": {
|
|
259
|
+
**_STR,
|
|
260
|
+
"description": "Paragraph p3#fp, run p3#fp..p5#fp or range p3#fp:10-15 "
|
|
261
|
+
"(run properties only for a range).",
|
|
262
|
+
},
|
|
263
|
+
"bold": {"type": "boolean"},
|
|
264
|
+
"italic": {"type": "boolean"},
|
|
265
|
+
"underline": {"type": "boolean"},
|
|
266
|
+
"small_caps": {"type": "boolean"},
|
|
267
|
+
"font": {**_STR, "description": "Font family name."},
|
|
268
|
+
"size_pt": {"type": "number", "minimum": 1, "maximum": 1638},
|
|
269
|
+
"align": {"enum": list(ALIGNMENTS)},
|
|
270
|
+
"indent_left_cm": {"type": "number", "minimum": -100, "maximum": 100},
|
|
271
|
+
"indent_right_cm": {"type": "number", "minimum": -100, "maximum": 100},
|
|
272
|
+
"indent_first_line_cm": {
|
|
273
|
+
"type": "number",
|
|
274
|
+
"minimum": -100,
|
|
275
|
+
"maximum": 100,
|
|
276
|
+
"description": "Negative makes a hanging indent.",
|
|
277
|
+
},
|
|
278
|
+
"spacing_before_pt": {"type": "number", "minimum": 0, "maximum": 1584},
|
|
279
|
+
"spacing_after_pt": {"type": "number", "minimum": 0, "maximum": 1584},
|
|
280
|
+
"line_spacing": {
|
|
281
|
+
"type": "number",
|
|
282
|
+
"minimum": 0.1,
|
|
283
|
+
"maximum": 20,
|
|
284
|
+
"description": "Line height as a multiple of single spacing.",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
"required": ["op", "at"],
|
|
288
|
+
"additionalProperties": False,
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def edit_from_dict(raw: Mapping[str, Any]) -> Edit:
|
|
294
|
+
"""Validate a request object (``{"op": "text.replace", ...}``) and build the edit."""
|
|
295
|
+
op = raw.get("op")
|
|
296
|
+
if not isinstance(op, str) or op not in OPERATION_SCHEMAS:
|
|
297
|
+
raise DocControlError(
|
|
298
|
+
ErrorCode.SCHEMA_INVALID,
|
|
299
|
+
f"unknown tracked operation: {op!r}",
|
|
300
|
+
expected=sorted(OPERATION_SCHEMAS),
|
|
301
|
+
)
|
|
302
|
+
validate(dict(raw), OPERATION_SCHEMAS[op])
|
|
303
|
+
if op == "text.replace":
|
|
304
|
+
return ReplaceText(raw["at"], raw["text"])
|
|
305
|
+
if op == "para.delete":
|
|
306
|
+
return DeleteParagraphs(raw["at"])
|
|
307
|
+
if op == "style.apply":
|
|
308
|
+
check_name(raw["style"], "style")
|
|
309
|
+
return ApplyStyle(raw["at"], raw["style"])
|
|
310
|
+
if op == "format.apply":
|
|
311
|
+
props = {k: raw[k] for k in (*RUN_KEYS, *PARAGRAPH_KEYS) if k in raw}
|
|
312
|
+
if not props:
|
|
313
|
+
raise DocControlError(
|
|
314
|
+
ErrorCode.SCHEMA_INVALID,
|
|
315
|
+
"format.apply needs at least one property",
|
|
316
|
+
expected=[*RUN_KEYS, *PARAGRAPH_KEYS],
|
|
317
|
+
)
|
|
318
|
+
if "font" in props:
|
|
319
|
+
check_name(props["font"], "font")
|
|
320
|
+
return ApplyFormat(raw["at"], props)
|
|
321
|
+
after, before = raw.get("after"), raw.get("before")
|
|
322
|
+
if (after is None) == (before is None):
|
|
323
|
+
raise DocControlError(
|
|
324
|
+
ErrorCode.SCHEMA_INVALID, f"{op} needs exactly one of 'after' and 'before'"
|
|
325
|
+
)
|
|
326
|
+
if op == "para.insert":
|
|
327
|
+
return InsertParagraph(raw["text"], after, before)
|
|
328
|
+
return MoveParagraphs(raw["at"], after, before)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# -- results ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass(frozen=True)
|
|
335
|
+
class TrackedResult:
|
|
336
|
+
applied: int
|
|
337
|
+
dry_run: bool
|
|
338
|
+
preview: list[dict[str, Any]]
|
|
339
|
+
address_map: dict[str, str]
|
|
340
|
+
footprint: list[str]
|
|
341
|
+
revision_ids: list[int]
|
|
342
|
+
author: str
|
|
343
|
+
date: str
|
|
344
|
+
before_sha256: str
|
|
345
|
+
after_sha256: str | None = None
|
|
346
|
+
backup: Path | None = None
|
|
347
|
+
warnings: list[str] = field(default_factory=list)
|
|
348
|
+
op_id: str | None = None
|
|
349
|
+
parts: dict[str, list[str]] = field(default_factory=dict) # other parts touched, with ids
|
|
350
|
+
comment_ids: list[str] = field(default_factory=list)
|
|
351
|
+
|
|
352
|
+
def to_dict(self) -> dict[str, Any]:
|
|
353
|
+
out: dict[str, Any] = {
|
|
354
|
+
"applied": self.applied,
|
|
355
|
+
"dry_run": self.dry_run,
|
|
356
|
+
"preview": self.preview,
|
|
357
|
+
"address_map": self.address_map,
|
|
358
|
+
"footprint": {DOCUMENT_PART: self.footprint, **self.parts},
|
|
359
|
+
"revision_ids": self.revision_ids,
|
|
360
|
+
"author": self.author,
|
|
361
|
+
"date": self.date,
|
|
362
|
+
}
|
|
363
|
+
if self.comment_ids:
|
|
364
|
+
out["comment_ids"] = self.comment_ids
|
|
365
|
+
if self.warnings:
|
|
366
|
+
out["warnings"] = self.warnings
|
|
367
|
+
if not self.dry_run:
|
|
368
|
+
out["sha256"] = self.after_sha256
|
|
369
|
+
out["backup"] = str(self.backup) if self.backup else None
|
|
370
|
+
out["op_id"] = self.op_id
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# -- checks on offsets and text ------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def boundary_ok(text: str, i: int) -> bool:
|
|
378
|
+
"""True if offset ``i`` of ``text`` does not split a character from its attachments."""
|
|
379
|
+
if i <= 0 or i >= len(text):
|
|
380
|
+
return True
|
|
381
|
+
ch, prev = text[i], text[i - 1]
|
|
382
|
+
if unicodedata.category(ch) in ("Mn", "Mc", "Me") or _ZWJ in (ch, prev):
|
|
383
|
+
return False
|
|
384
|
+
if "\U0001f3fb" <= ch <= "\U0001f3ff" or "\U000e0020" <= ch <= "\U000e007f":
|
|
385
|
+
return False
|
|
386
|
+
if "\U0001f1e6" <= ch <= "\U0001f1ff": # inside a flag: an odd run of indicators before it
|
|
387
|
+
run = 0
|
|
388
|
+
while run < i and "\U0001f1e6" <= text[i - 1 - run] <= "\U0001f1ff":
|
|
389
|
+
run += 1
|
|
390
|
+
return run % 2 == 0
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _check_boundary(para: Paragraph, offset: int, at: str) -> None:
|
|
395
|
+
if not boundary_ok(para.text, offset):
|
|
396
|
+
raise DocControlError(
|
|
397
|
+
ErrorCode.ADDRESS_INVALID,
|
|
398
|
+
f"offset {offset} of {para.address} splits a character from its combining marks",
|
|
399
|
+
address=at,
|
|
400
|
+
offset=offset,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def check_text(text: str, what: str) -> None:
|
|
405
|
+
for ch in text:
|
|
406
|
+
code = ord(ch)
|
|
407
|
+
bad = (
|
|
408
|
+
(code < 0x20 and ch not in "\t\n")
|
|
409
|
+
or 0xD800 <= code <= 0xDFFF
|
|
410
|
+
or code in (0xFFFE, 0xFFFF)
|
|
411
|
+
)
|
|
412
|
+
if bad:
|
|
413
|
+
raise DocControlError(
|
|
414
|
+
ErrorCode.SCHEMA_INVALID,
|
|
415
|
+
f"{what} holds U+{code:04X}, which Word text cannot contain",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def check_name(value: str, what: str) -> None:
|
|
420
|
+
if not value.strip():
|
|
421
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"{what} must not be empty")
|
|
422
|
+
check_text(value, what)
|
|
423
|
+
if "\t" in value or "\n" in value:
|
|
424
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"{what} must be one line")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _short(text: str) -> str:
|
|
428
|
+
return text if len(text) <= PREVIEW_LIMIT else text[: PREVIEW_LIMIT - 1] + "…"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# -- layout: which paragraphs are neighbours ----------------------------------------------
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _inert_gap(gap: bytes) -> bool:
|
|
435
|
+
"""True if the bytes between two paragraphs hold only whitespace and markers."""
|
|
436
|
+
gap = _SKIPPABLE.sub(b"", gap)
|
|
437
|
+
pos = 0
|
|
438
|
+
for m in _TAG.finditer(gap):
|
|
439
|
+
if gap[pos : m.start()].strip():
|
|
440
|
+
return False
|
|
441
|
+
if m.group(2).rsplit(b":", 1)[-1] not in _INERT_LOCALS:
|
|
442
|
+
return False
|
|
443
|
+
pos = m.end()
|
|
444
|
+
return not gap[pos:].strip()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class _Layout:
|
|
448
|
+
"""Neighbour test: paragraph ``b`` follows ``a`` in the same container."""
|
|
449
|
+
|
|
450
|
+
def __init__(self, index: DocumentIndex) -> None:
|
|
451
|
+
self.index = index
|
|
452
|
+
|
|
453
|
+
def next_adjacent(self, para: Paragraph) -> Paragraph | None:
|
|
454
|
+
if para.number >= len(self.index):
|
|
455
|
+
return None
|
|
456
|
+
nxt = self.index.paragraphs[para.number]
|
|
457
|
+
spans = self.index.spans
|
|
458
|
+
a, b = spans.paragraphs[para.span_index], spans.paragraphs[nxt.span_index]
|
|
459
|
+
if b.start < a.end or b.depth != a.depth or b.parent != a.parent:
|
|
460
|
+
return None
|
|
461
|
+
return nxt if _inert_gap(spans.data[a.end : b.start]) else None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# -- the plan --------------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@dataclass
|
|
468
|
+
class _Interval:
|
|
469
|
+
start: int
|
|
470
|
+
end: int
|
|
471
|
+
text: str
|
|
472
|
+
edit: int
|
|
473
|
+
|
|
474
|
+
def clashes(self, other: _Interval) -> bool:
|
|
475
|
+
a, b = self, other
|
|
476
|
+
if a.start == a.end and b.start == b.end:
|
|
477
|
+
return a.start == b.start
|
|
478
|
+
if a.start == a.end:
|
|
479
|
+
return b.start <= a.start <= b.end
|
|
480
|
+
if b.start == b.end:
|
|
481
|
+
return a.start <= b.start <= a.end
|
|
482
|
+
return a.start < b.end and b.start < a.end
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@dataclass
|
|
486
|
+
class _Format:
|
|
487
|
+
"""Run properties to set on visible characters ``[start, end)`` of a paragraph."""
|
|
488
|
+
|
|
489
|
+
start: int
|
|
490
|
+
end: int
|
|
491
|
+
props: dict[str, Any]
|
|
492
|
+
edit: int
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@dataclass
|
|
496
|
+
class _ParaPlan:
|
|
497
|
+
para: Paragraph
|
|
498
|
+
intervals: list[_Interval] = field(default_factory=list)
|
|
499
|
+
formats: list[_Format] = field(default_factory=list)
|
|
500
|
+
ppr: dict[str, Any] = field(default_factory=dict) # paragraph properties to set
|
|
501
|
+
ppr_by: dict[str, int] = field(default_factory=dict) # property -> edit that sets it
|
|
502
|
+
delete_all: bool = False
|
|
503
|
+
mark: str | None = None
|
|
504
|
+
touchers: set[int] = field(default_factory=set)
|
|
505
|
+
exclusive: bool = False
|
|
506
|
+
moved_by: int | None = None # the move edit that carries this paragraph away
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@dataclass
|
|
510
|
+
class _NewParagraph:
|
|
511
|
+
"""A paragraph to add: its text, or the paragraph whose runs it copies."""
|
|
512
|
+
|
|
513
|
+
edit: int
|
|
514
|
+
slot: tuple[str, int] # ("before" | "after", span index of the anchor)
|
|
515
|
+
ref: Paragraph # properties come from here
|
|
516
|
+
text: str | None
|
|
517
|
+
source: Paragraph | None # a moved paragraph (copy its runs), else None
|
|
518
|
+
native: bool # the reference paragraph's mark is the inserted one
|
|
519
|
+
expected: str = ""
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class _Planner:
|
|
523
|
+
def __init__(
|
|
524
|
+
self, index: DocumentIndex, *, layered: bool = False, pkg: Package | None = None
|
|
525
|
+
) -> None:
|
|
526
|
+
self.index = index
|
|
527
|
+
self.pkg = pkg
|
|
528
|
+
self._styles: StyleTable | None = None
|
|
529
|
+
self.layered = layered
|
|
530
|
+
self.layout = _Layout(index)
|
|
531
|
+
self.plans: dict[int, _ParaPlan] = {}
|
|
532
|
+
self.new: list[_NewParagraph] = []
|
|
533
|
+
self.slots: dict[tuple[str, int], int] = {}
|
|
534
|
+
self.previews: list[dict[str, Any]] = []
|
|
535
|
+
self.removed: dict[int, int] = {} # paragraph number -> op that deletes or moves it
|
|
536
|
+
self.anchored: dict[int, set[int]] = {} # paragraph number -> ops placed relative to it
|
|
537
|
+
|
|
538
|
+
# -- bookkeeping --
|
|
539
|
+
|
|
540
|
+
def _conflict(self, para: Paragraph, a: int, b: int) -> DocControlError:
|
|
541
|
+
return DocControlError(
|
|
542
|
+
ErrorCode.BATCH_CONFLICT,
|
|
543
|
+
f"edit {max(a, b)} is placed relative to {para.address}, which edit "
|
|
544
|
+
f"{min(a, b)} deletes or moves: addresses refer to the document as read",
|
|
545
|
+
paragraph=str(para.address),
|
|
546
|
+
edits=sorted((a, b)),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def _anchor(self, para: Paragraph, edit: int) -> None:
|
|
550
|
+
"""Edit ``edit`` inserts or moves relative to ``para``."""
|
|
551
|
+
other = self.removed.get(para.number)
|
|
552
|
+
if other is not None and other != edit and not self.layered:
|
|
553
|
+
raise self._conflict(para, other, edit)
|
|
554
|
+
self.anchored.setdefault(para.number, set()).add(edit)
|
|
555
|
+
|
|
556
|
+
def _remove(self, para: Paragraph, edit: int) -> None:
|
|
557
|
+
"""Edit ``edit`` deletes or moves ``para`` away."""
|
|
558
|
+
for other in sorted(self.anchored.get(para.number, ())):
|
|
559
|
+
if other != edit and not self.layered:
|
|
560
|
+
raise self._conflict(para, other, edit)
|
|
561
|
+
self.removed[para.number] = edit
|
|
562
|
+
|
|
563
|
+
def _plan(self, para: Paragraph, edit: int, *, exclusive: bool) -> _ParaPlan:
|
|
564
|
+
plan = self.plans.setdefault(para.number, _ParaPlan(para))
|
|
565
|
+
others = plan.touchers - {edit}
|
|
566
|
+
if others and (exclusive or plan.exclusive):
|
|
567
|
+
raise DocControlError(
|
|
568
|
+
ErrorCode.BATCH_CONFLICT,
|
|
569
|
+
f"edits {min(others)} and {edit} both change {para.address}",
|
|
570
|
+
paragraph=str(para.address),
|
|
571
|
+
edits=sorted(others | {edit}),
|
|
572
|
+
)
|
|
573
|
+
plan.touchers.add(edit)
|
|
574
|
+
plan.exclusive = plan.exclusive or exclusive
|
|
575
|
+
return plan
|
|
576
|
+
|
|
577
|
+
def _check_overlap(self, plan: _ParaPlan, new: _Interval) -> None:
|
|
578
|
+
olds = [*plan.intervals, *(_Interval(f.start, f.end, "", f.edit) for f in plan.formats)]
|
|
579
|
+
for old in olds:
|
|
580
|
+
if old.edit != new.edit and old.clashes(new):
|
|
581
|
+
raise DocControlError(
|
|
582
|
+
ErrorCode.BATCH_CONFLICT,
|
|
583
|
+
f"edits {old.edit} and {new.edit} overlap in {plan.para.address}",
|
|
584
|
+
paragraph=str(plan.para.address),
|
|
585
|
+
edits=sorted([old.edit, new.edit]),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def _add_interval(self, plan: _ParaPlan, new: _Interval) -> None:
|
|
589
|
+
self._check_overlap(plan, new)
|
|
590
|
+
plan.intervals.append(new)
|
|
591
|
+
|
|
592
|
+
def _add_format(self, plan: _ParaPlan, new: _Format) -> None:
|
|
593
|
+
self._check_overlap(plan, _Interval(new.start, new.end, "", new.edit))
|
|
594
|
+
plan.formats.append(new)
|
|
595
|
+
|
|
596
|
+
def _claim_slot(self, slot: tuple[str, int], edit: int) -> None:
|
|
597
|
+
if slot in self.slots and self.slots[slot] != edit and not self.layered:
|
|
598
|
+
raise DocControlError(
|
|
599
|
+
ErrorCode.BATCH_CONFLICT,
|
|
600
|
+
f"edits {self.slots[slot]} and {edit} insert at the same place",
|
|
601
|
+
edits=[self.slots[slot], edit],
|
|
602
|
+
)
|
|
603
|
+
self.slots.setdefault(slot, edit)
|
|
604
|
+
|
|
605
|
+
def _run_of(self, at: str) -> list[Paragraph]:
|
|
606
|
+
"""Paragraphs of ``pN#fp`` or ``pN#fp..pM#fp`` (adjacent, in one container)."""
|
|
607
|
+
first, sep, last = at.partition("..")
|
|
608
|
+
a = self.index.resolve_address(parse_address(first))
|
|
609
|
+
if not sep:
|
|
610
|
+
return [a]
|
|
611
|
+
b = self.index.resolve_address(parse_address(last))
|
|
612
|
+
if b.number < a.number:
|
|
613
|
+
raise DocControlError(
|
|
614
|
+
ErrorCode.ADDRESS_INVALID, f"{at!r} ends before it starts", address=at
|
|
615
|
+
)
|
|
616
|
+
paras = list(self.index.paragraphs[a.number - 1 : b.number])
|
|
617
|
+
self._require_adjacent(paras, at)
|
|
618
|
+
return paras
|
|
619
|
+
|
|
620
|
+
def _require_adjacent(self, paras: Sequence[Paragraph], at: str) -> None:
|
|
621
|
+
for prev, nxt in itertools.pairwise(paras):
|
|
622
|
+
if self.layout.next_adjacent(prev) is not nxt:
|
|
623
|
+
raise DocControlError(
|
|
624
|
+
ErrorCode.UNSUPPORTED_CONTENT,
|
|
625
|
+
f"{prev.address} and {nxt.address} are not neighbours in the same "
|
|
626
|
+
"container (a table, cell edge or content control lies between)",
|
|
627
|
+
address=at,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def _slot_for(self, anchor: Paragraph, where: str, edit: int) -> tuple[tuple[str, int], bool]:
|
|
631
|
+
"""``(slot, native)`` for inserting before/after ``anchor``."""
|
|
632
|
+
if where == "before":
|
|
633
|
+
slot = ("before", anchor.span_index)
|
|
634
|
+
self._claim_slot(slot, edit)
|
|
635
|
+
return slot, False
|
|
636
|
+
nxt = self.layout.next_adjacent(anchor)
|
|
637
|
+
if nxt is not None:
|
|
638
|
+
slot = ("before", nxt.span_index)
|
|
639
|
+
self._claim_slot(slot, edit)
|
|
640
|
+
return slot, False
|
|
641
|
+
slot = ("after", anchor.span_index)
|
|
642
|
+
self._claim_slot(slot, edit)
|
|
643
|
+
return slot, True
|
|
644
|
+
|
|
645
|
+
# -- the four edits --
|
|
646
|
+
|
|
647
|
+
def replace(self, i: int, edit: ReplaceText) -> None:
|
|
648
|
+
check_text(edit.text, "text")
|
|
649
|
+
rng = self.index.resolve_range(edit.at)
|
|
650
|
+
if rng.sentence is not None:
|
|
651
|
+
raise DocControlError(
|
|
652
|
+
ErrorCode.ADDRESS_INVALID,
|
|
653
|
+
"sentence addresses need segmentation, which is not available yet",
|
|
654
|
+
address=edit.at,
|
|
655
|
+
)
|
|
656
|
+
first, last = rng.first, rng.last
|
|
657
|
+
assert rng.start_offset is not None and rng.end_offset is not None
|
|
658
|
+
s, e = rng.start_offset, rng.end_offset
|
|
659
|
+
_check_boundary(first, s, edit.at)
|
|
660
|
+
_check_boundary(last, e, edit.at)
|
|
661
|
+
if first is last:
|
|
662
|
+
if s == e and not edit.text:
|
|
663
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "text.replace would change nothing")
|
|
664
|
+
plan = self._plan(first, i, exclusive=False)
|
|
665
|
+
self._add_interval(plan, _Interval(s, e, edit.text, i))
|
|
666
|
+
self.previews.append(
|
|
667
|
+
{
|
|
668
|
+
"op": i,
|
|
669
|
+
"at": edit.at,
|
|
670
|
+
"before": _short(first.text[s:e]),
|
|
671
|
+
"after": _short(edit.text),
|
|
672
|
+
}
|
|
673
|
+
)
|
|
674
|
+
return
|
|
675
|
+
paras = list(self.index.paragraphs[first.number - 1 : last.number])
|
|
676
|
+
self._require_adjacent(paras, edit.at)
|
|
677
|
+
for para in paras:
|
|
678
|
+
if para is not last:
|
|
679
|
+
self._remove(para, i)
|
|
680
|
+
plan = self._plan(para, i, exclusive=para is not last and not self.layered)
|
|
681
|
+
if para is first:
|
|
682
|
+
self._add_interval(plan, _Interval(s, len(para.text), edit.text, i))
|
|
683
|
+
elif para is last:
|
|
684
|
+
if e:
|
|
685
|
+
self._add_interval(plan, _Interval(0, e, "", i))
|
|
686
|
+
else:
|
|
687
|
+
plan.delete_all = True
|
|
688
|
+
if para is not last:
|
|
689
|
+
plan.mark = "del"
|
|
690
|
+
removed = "\n".join([first.text[s:], *(p.text for p in paras[1:-1]), last.text[:e]])
|
|
691
|
+
self.previews.append(
|
|
692
|
+
{"op": i, "at": edit.at, "before": _short(removed), "after": _short(edit.text)}
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
def delete(self, i: int, edit: DeleteParagraphs) -> None:
|
|
696
|
+
paras = self._run_of(edit.at)
|
|
697
|
+
if self.layout.next_adjacent(paras[-1]) is None:
|
|
698
|
+
raise DocControlError(
|
|
699
|
+
ErrorCode.UNSUPPORTED_CONTENT,
|
|
700
|
+
f"{paras[-1].address} is the last paragraph of its cell, section or the "
|
|
701
|
+
"body: Word cannot delete that mark; delete its text with text.replace",
|
|
702
|
+
address=edit.at,
|
|
703
|
+
)
|
|
704
|
+
for para in paras:
|
|
705
|
+
self._remove(para, i)
|
|
706
|
+
plan = self._plan(para, i, exclusive=True)
|
|
707
|
+
plan.delete_all = True
|
|
708
|
+
plan.mark = "del"
|
|
709
|
+
self.previews.append(
|
|
710
|
+
{
|
|
711
|
+
"op": i,
|
|
712
|
+
"at": edit.at,
|
|
713
|
+
"before": _short("\n".join(p.text for p in paras)),
|
|
714
|
+
"after": "(deleted)",
|
|
715
|
+
}
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
def insert(self, i: int, edit: InsertParagraph) -> None:
|
|
719
|
+
check_text(edit.text, "text")
|
|
720
|
+
where, anchor_at = (
|
|
721
|
+
("after", edit.after) if edit.after is not None else ("before", edit.before)
|
|
722
|
+
)
|
|
723
|
+
assert anchor_at is not None
|
|
724
|
+
anchor = self.index.resolve_address(parse_address(anchor_at))
|
|
725
|
+
self._anchor(anchor, i)
|
|
726
|
+
slot, native = self._slot_for(anchor, where, i)
|
|
727
|
+
if native:
|
|
728
|
+
self._plan(anchor, i, exclusive=not self.layered).mark = "ins"
|
|
729
|
+
self.new.append(_NewParagraph(i, slot, anchor, edit.text, None, native, edit.text))
|
|
730
|
+
self.previews.append(
|
|
731
|
+
{"op": i, "at": f"{where} {anchor.address}", "before": "", "after": _short(edit.text)}
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
def move(self, i: int, edit: MoveParagraphs) -> None:
|
|
735
|
+
paras = self._run_of(edit.at)
|
|
736
|
+
where, target_at = (
|
|
737
|
+
("after", edit.after) if edit.after is not None else ("before", edit.before)
|
|
738
|
+
)
|
|
739
|
+
assert target_at is not None
|
|
740
|
+
target = self.index.resolve_address(parse_address(target_at))
|
|
741
|
+
lo, hi = paras[0].number, paras[-1].number
|
|
742
|
+
inside = (
|
|
743
|
+
(lo - 1 <= target.number <= hi) if where == "after" else (lo <= target.number <= hi + 1)
|
|
744
|
+
)
|
|
745
|
+
if inside:
|
|
746
|
+
raise DocControlError(
|
|
747
|
+
ErrorCode.ADDRESS_INVALID,
|
|
748
|
+
f"the target {target.address} is inside or right next to the moved paragraphs",
|
|
749
|
+
address=target_at,
|
|
750
|
+
)
|
|
751
|
+
if self.layout.next_adjacent(paras[-1]) is None:
|
|
752
|
+
raise DocControlError(
|
|
753
|
+
ErrorCode.UNSUPPORTED_CONTENT,
|
|
754
|
+
f"{paras[-1].address} is the last paragraph of its cell, section or the body: "
|
|
755
|
+
"it cannot be moved away",
|
|
756
|
+
address=edit.at,
|
|
757
|
+
)
|
|
758
|
+
self._anchor(target, i)
|
|
759
|
+
slot, native = self._slot_for(target, where, i)
|
|
760
|
+
if native:
|
|
761
|
+
# After the end of a container (the body's last paragraph): Word's own form. The
|
|
762
|
+
# target's mark becomes the inserted one and the last copy keeps the original mark
|
|
763
|
+
# (and the target's properties), so rejecting gives the target back untouched.
|
|
764
|
+
self._plan(target, i, exclusive=not self.layered).mark = "ins"
|
|
765
|
+
for para in paras:
|
|
766
|
+
self._remove(para, i)
|
|
767
|
+
plan = self._plan(para, i, exclusive=True)
|
|
768
|
+
plan.delete_all = True
|
|
769
|
+
plan.mark = "del"
|
|
770
|
+
plan.moved_by = i
|
|
771
|
+
last = native and para is paras[-1]
|
|
772
|
+
self.new.append(
|
|
773
|
+
_NewParagraph(i, slot, target if last else para, None, para, last, para.text)
|
|
774
|
+
)
|
|
775
|
+
self.previews.append(
|
|
776
|
+
{
|
|
777
|
+
"op": i,
|
|
778
|
+
"at": edit.at,
|
|
779
|
+
"before": _short("\n".join(p.text for p in paras)),
|
|
780
|
+
"after": f"(moved {where} {target.address})",
|
|
781
|
+
}
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
# -- formatting --
|
|
785
|
+
|
|
786
|
+
@property
|
|
787
|
+
def styles(self) -> StyleTable:
|
|
788
|
+
if self._styles is None:
|
|
789
|
+
assert self.pkg is not None
|
|
790
|
+
self._styles = load_styles(self.pkg)
|
|
791
|
+
return self._styles
|
|
792
|
+
|
|
793
|
+
def _format_targets(self, at: str) -> list[tuple[Paragraph, int, int, bool]]:
|
|
794
|
+
"""``(paragraph, start, end, whole)`` for a paragraph, a run of paragraphs or a range."""
|
|
795
|
+
if ".." in at and ":" not in at:
|
|
796
|
+
first_at, _, last_at = at.partition("..")
|
|
797
|
+
a = self.index.resolve_address(parse_address(first_at))
|
|
798
|
+
b = self.index.resolve_address(parse_address(last_at))
|
|
799
|
+
if b.number < a.number:
|
|
800
|
+
raise DocControlError(
|
|
801
|
+
ErrorCode.ADDRESS_INVALID, f"{at!r} ends before it starts", address=at
|
|
802
|
+
)
|
|
803
|
+
return [
|
|
804
|
+
(p, 0, len(p.text), True) for p in self.index.paragraphs[a.number - 1 : b.number]
|
|
805
|
+
]
|
|
806
|
+
rng = self.index.resolve_range(at)
|
|
807
|
+
if rng.sentence is not None:
|
|
808
|
+
raise DocControlError(
|
|
809
|
+
ErrorCode.ADDRESS_INVALID,
|
|
810
|
+
"sentence addresses need segmentation, which is not available yet",
|
|
811
|
+
address=at,
|
|
812
|
+
)
|
|
813
|
+
assert rng.start_offset is not None and rng.end_offset is not None
|
|
814
|
+
whole = rng.range.start_offset is None
|
|
815
|
+
if not whole:
|
|
816
|
+
_check_boundary(rng.first, rng.start_offset, at)
|
|
817
|
+
_check_boundary(rng.last, rng.end_offset, at)
|
|
818
|
+
paras = self.index.paragraphs[rng.first.number - 1 : rng.last.number]
|
|
819
|
+
return [
|
|
820
|
+
(
|
|
821
|
+
p,
|
|
822
|
+
rng.start_offset if p is rng.first else 0,
|
|
823
|
+
rng.end_offset if p is rng.last else len(p.text),
|
|
824
|
+
whole,
|
|
825
|
+
)
|
|
826
|
+
for p in paras
|
|
827
|
+
]
|
|
828
|
+
|
|
829
|
+
def _set_ppr(self, i: int, para: Paragraph, props: dict[str, Any]) -> None:
|
|
830
|
+
plan = self._plan(para, i, exclusive=False)
|
|
831
|
+
for key in props:
|
|
832
|
+
other = plan.ppr_by.get(key)
|
|
833
|
+
if other is not None and other != i:
|
|
834
|
+
raise DocControlError(
|
|
835
|
+
ErrorCode.BATCH_CONFLICT,
|
|
836
|
+
f"edits {other} and {i} both set {key} of {para.address}",
|
|
837
|
+
paragraph=str(para.address),
|
|
838
|
+
edits=sorted([other, i]),
|
|
839
|
+
)
|
|
840
|
+
plan.ppr_by[key] = i
|
|
841
|
+
plan.ppr.update(props)
|
|
842
|
+
|
|
843
|
+
def _set_runs(self, i: int, para: Paragraph, s: int, e: int, props: dict[str, Any]) -> None:
|
|
844
|
+
if s == e:
|
|
845
|
+
return # no text, no run to format
|
|
846
|
+
plan = self._plan(para, i, exclusive=False)
|
|
847
|
+
self._add_format(plan, _Format(s, e, props, i))
|
|
848
|
+
|
|
849
|
+
def _previous_styles(self, paras: Sequence[Paragraph]) -> str:
|
|
850
|
+
found: list[str] = []
|
|
851
|
+
for para in paras:
|
|
852
|
+
elem = self.index.spans.parse_paragraph(para.span_index)
|
|
853
|
+
ppr = elem.find(f"{{{W_NS}}}pPr")
|
|
854
|
+
style = ppr.find(f"{{{W_NS}}}pStyle") if ppr is not None else None
|
|
855
|
+
value = (style.get(f"{{{W_NS}}}val") if style is not None else None) or "(default)"
|
|
856
|
+
if value not in found:
|
|
857
|
+
found.append(value)
|
|
858
|
+
return ", ".join(found)
|
|
859
|
+
|
|
860
|
+
def style(self, i: int, edit: ApplyStyle) -> None:
|
|
861
|
+
found = self.styles.find(edit.style)
|
|
862
|
+
if found is None:
|
|
863
|
+
raise DocControlError(
|
|
864
|
+
ErrorCode.STYLE_NOT_FOUND,
|
|
865
|
+
f"no style {edit.style!r} in the document",
|
|
866
|
+
style=edit.style,
|
|
867
|
+
)
|
|
868
|
+
targets = self._format_targets(edit.at)
|
|
869
|
+
if found.type == "paragraph":
|
|
870
|
+
if not all(whole for *_, whole in targets):
|
|
871
|
+
raise DocControlError(
|
|
872
|
+
ErrorCode.SCHEMA_INVALID,
|
|
873
|
+
f"{found.id!r} is a paragraph style: address whole paragraphs, not text",
|
|
874
|
+
address=edit.at,
|
|
875
|
+
)
|
|
876
|
+
for para, _, _, _ in targets:
|
|
877
|
+
self._set_ppr(i, para, {"style": found.id})
|
|
878
|
+
before = self._previous_styles([t[0] for t in targets])
|
|
879
|
+
elif found.type == "character":
|
|
880
|
+
for para, s, e, _ in targets:
|
|
881
|
+
self._set_runs(i, para, s, e, {"style": found.id})
|
|
882
|
+
before = _short(targets[0][0].text[targets[0][1] : targets[0][2]])
|
|
883
|
+
else:
|
|
884
|
+
raise DocControlError(
|
|
885
|
+
ErrorCode.SCHEMA_INVALID,
|
|
886
|
+
f"{found.id!r} is a {found.type} style: only paragraph and character "
|
|
887
|
+
"styles can be applied here",
|
|
888
|
+
style=found.id,
|
|
889
|
+
)
|
|
890
|
+
self.previews.append({"op": i, "at": edit.at, "before": before, "after": found.id})
|
|
891
|
+
|
|
892
|
+
def format(self, i: int, edit: ApplyFormat) -> None:
|
|
893
|
+
run_props = {k: v for k, v in edit.props.items() if k in RUN_KEYS}
|
|
894
|
+
para_props = {k: v for k, v in edit.props.items() if k in PARAGRAPH_KEYS}
|
|
895
|
+
targets = self._format_targets(edit.at)
|
|
896
|
+
if para_props and not all(whole for *_, whole in targets):
|
|
897
|
+
raise DocControlError(
|
|
898
|
+
ErrorCode.SCHEMA_INVALID,
|
|
899
|
+
f"{sorted(para_props)} apply to whole paragraphs, not a text range",
|
|
900
|
+
address=edit.at,
|
|
901
|
+
)
|
|
902
|
+
for para, s, e, _ in targets:
|
|
903
|
+
if para_props:
|
|
904
|
+
self._set_ppr(i, para, para_props)
|
|
905
|
+
if run_props:
|
|
906
|
+
self._set_runs(i, para, s, e, run_props)
|
|
907
|
+
first, s, e, _ = targets[0]
|
|
908
|
+
self.previews.append(
|
|
909
|
+
{
|
|
910
|
+
"op": i,
|
|
911
|
+
"at": edit.at,
|
|
912
|
+
"before": _short(first.text[s:e]),
|
|
913
|
+
"after": ", ".join(f"{k}={v}" for k, v in edit.props.items()),
|
|
914
|
+
}
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
# -- applying ---------------------------------------------------------------------------------
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _serialize(spans: DocumentSpans, elem: ET.Element, at: int) -> bytes:
|
|
922
|
+
try:
|
|
923
|
+
return spans.serialize(elem, at)
|
|
924
|
+
except ValueError as exc:
|
|
925
|
+
raise DocControlError(ErrorCode.INTERNAL, f"cannot write the paragraph: {exc}") from exc
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _merge_marks(texts: Sequence[str], carry_forward: Sequence[bool]) -> list[str]:
|
|
929
|
+
"""Join paragraphs whose mark is gone into the paragraph after them."""
|
|
930
|
+
out: list[str] = []
|
|
931
|
+
carry = ""
|
|
932
|
+
for text, joins in zip(texts, carry_forward, strict=True):
|
|
933
|
+
text = carry + text
|
|
934
|
+
if joins:
|
|
935
|
+
carry = text
|
|
936
|
+
else:
|
|
937
|
+
out.append(text)
|
|
938
|
+
carry = ""
|
|
939
|
+
if carry:
|
|
940
|
+
out.append(carry)
|
|
941
|
+
return out
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
@dataclass(frozen=True)
|
|
945
|
+
class PreparedEdits:
|
|
946
|
+
"""The edited ``word/document.xml`` of a package, checked but not written anywhere."""
|
|
947
|
+
|
|
948
|
+
pkg: Package
|
|
949
|
+
new_data: bytes
|
|
950
|
+
footprint: Footprint
|
|
951
|
+
result: TrackedResult
|
|
952
|
+
changes: list[dict[str, Any]] = field(default_factory=list)
|
|
953
|
+
"""The undo-log record of the edit: each touched paragraph's bytes before and after."""
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def next_in_container(index: DocumentIndex, para: Paragraph) -> Paragraph | None:
|
|
957
|
+
"""The paragraph right after ``para`` in the same container, or None if ``para`` is the
|
|
958
|
+
last of its cell, section or the body (Word cannot delete such a mark)."""
|
|
959
|
+
return _Layout(index).next_adjacent(para)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def prepare_edits(
|
|
963
|
+
pkg: Package,
|
|
964
|
+
edits: Sequence[Any],
|
|
965
|
+
*,
|
|
966
|
+
author: str = DEFAULT_AUTHOR,
|
|
967
|
+
tag: str | None = None,
|
|
968
|
+
now: datetime | None = None,
|
|
969
|
+
dry_run: bool = False,
|
|
970
|
+
layered: bool = False,
|
|
971
|
+
index: DocumentIndex | None = None,
|
|
972
|
+
annotations: Mapping[int, Annotator] | None = None,
|
|
973
|
+
reserved_ids: Collection[int] = (),
|
|
974
|
+
) -> PreparedEdits:
|
|
975
|
+
"""Plan and build ``edits`` on ``pkg`` without writing: the part of :func:`apply_edits`
|
|
976
|
+
that works on bytes, self-checked (see there).
|
|
977
|
+
|
|
978
|
+
With ``layered`` several paragraphs may be inserted at the same place (they
|
|
979
|
+
keep the order of the edits), a paragraph may be edited and also be the anchor of an
|
|
980
|
+
insertion after the end of its container, and the first paragraph of a range across
|
|
981
|
+
paragraphs may carry other (non-overlapping) edits; used by ``compare``.
|
|
982
|
+
|
|
983
|
+
``annotations`` maps paragraph numbers to functions that rewrite that paragraph's
|
|
984
|
+
element without changing its text (comment anchors); such a paragraph must not
|
|
985
|
+
also be edited (``BATCH_CONFLICT``). ``reserved_ids`` are ``w:id`` values already
|
|
986
|
+
taken for other markup, which no revision gets. ``edits`` may be empty when
|
|
987
|
+
annotations are given.
|
|
988
|
+
"""
|
|
989
|
+
annotations = annotations or {}
|
|
990
|
+
if not edits and not annotations:
|
|
991
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "no edits given")
|
|
992
|
+
check_name(author, "author")
|
|
993
|
+
if tag is not None:
|
|
994
|
+
check_name(tag, "tag")
|
|
995
|
+
if index is None:
|
|
996
|
+
index = DocumentIndex.from_package(pkg)
|
|
997
|
+
|
|
998
|
+
planner = _Planner(index, layered=layered, pkg=pkg)
|
|
999
|
+
for i, edit in enumerate(edits):
|
|
1000
|
+
if isinstance(edit, ReplaceText):
|
|
1001
|
+
planner.replace(i, edit)
|
|
1002
|
+
elif isinstance(edit, InsertParagraph):
|
|
1003
|
+
planner.insert(i, edit)
|
|
1004
|
+
elif isinstance(edit, DeleteParagraphs):
|
|
1005
|
+
planner.delete(i, edit)
|
|
1006
|
+
elif isinstance(edit, MoveParagraphs):
|
|
1007
|
+
planner.move(i, edit)
|
|
1008
|
+
elif isinstance(edit, ApplyStyle):
|
|
1009
|
+
planner.style(i, edit)
|
|
1010
|
+
elif isinstance(edit, ApplyFormat):
|
|
1011
|
+
planner.format(i, edit)
|
|
1012
|
+
# anything else (a comment operation) is handled by the caller through ``annotations``
|
|
1013
|
+
for number in sorted(annotations):
|
|
1014
|
+
if number in planner.plans:
|
|
1015
|
+
raise DocControlError(
|
|
1016
|
+
ErrorCode.BATCH_CONFLICT,
|
|
1017
|
+
f"a comment is placed on {index.paragraph(number).address}, which another "
|
|
1018
|
+
"operation of the batch edits, deletes or moves: apply them in separate batches",
|
|
1019
|
+
paragraph=str(index.paragraph(number).address),
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
ctx = RevisionContext(
|
|
1023
|
+
used_ids(pkg) | set(reserved_ids), format_author(author, tag), format_date(now)
|
|
1024
|
+
)
|
|
1025
|
+
spans = index.spans
|
|
1026
|
+
scopes = _bookmark_scopes(pkg, index, planner, ctx)
|
|
1027
|
+
replacements: dict[int, bytes] = {}
|
|
1028
|
+
for number in sorted(planner.plans):
|
|
1029
|
+
plan = planner.plans[number]
|
|
1030
|
+
para = plan.para
|
|
1031
|
+
elem = spans.parse_paragraph(para.span_index)
|
|
1032
|
+
editor = ParagraphEditor(elem, para, ctx)
|
|
1033
|
+
if plan.delete_all:
|
|
1034
|
+
if plan.moved_by is not None:
|
|
1035
|
+
editor.detach_bookmarks(scopes[plan.moved_by])
|
|
1036
|
+
editor.delete_content()
|
|
1037
|
+
_require_no_live_text(elem, para)
|
|
1038
|
+
steps: list[_Interval | _Format] = [*plan.intervals, *plan.formats]
|
|
1039
|
+
for part in sorted(steps, key=lambda x: (x.start, x.end), reverse=True):
|
|
1040
|
+
if isinstance(part, _Interval):
|
|
1041
|
+
editor.replace(part.start, part.end, part.text)
|
|
1042
|
+
else:
|
|
1043
|
+
editor.format_range(part.start, part.end, part.props)
|
|
1044
|
+
if plan.mark:
|
|
1045
|
+
editor.mark(plan.mark)
|
|
1046
|
+
if plan.ppr:
|
|
1047
|
+
editor.format_paragraph(plan.ppr)
|
|
1048
|
+
replacements[para.span_index] = _serialize(spans, elem, para.span_index)
|
|
1049
|
+
for number in sorted(annotations):
|
|
1050
|
+
para = index.paragraph(number)
|
|
1051
|
+
elem = spans.parse_paragraph(para.span_index)
|
|
1052
|
+
annotations[number](elem, para, ctx)
|
|
1053
|
+
replacements[para.span_index] = _serialize(spans, elem, para.span_index)
|
|
1054
|
+
|
|
1055
|
+
after: dict[int, list[bytes]] = {}
|
|
1056
|
+
before: dict[int, list[bytes]] = {}
|
|
1057
|
+
last_native = {item.slot: item for item in planner.new if item.native}
|
|
1058
|
+
for item in planner.new:
|
|
1059
|
+
ref_elem = spans.parse_paragraph(item.ref.span_index)
|
|
1060
|
+
# Several paragraphs after the end of a container: only the last keeps the original mark.
|
|
1061
|
+
native = item.native and last_native[item.slot] is item
|
|
1062
|
+
p, props = new_paragraph(ref_elem, ctx, inserted_mark=not native)
|
|
1063
|
+
content: ET.Element | None
|
|
1064
|
+
if item.source is not None:
|
|
1065
|
+
source_elem = spans.parse_paragraph(item.source.span_index)
|
|
1066
|
+
runs = ParagraphEditor(source_elem, item.source, ctx).copy_runs(scopes.get(item.edit))
|
|
1067
|
+
content = inserted_content(ctx, runs=runs)
|
|
1068
|
+
else:
|
|
1069
|
+
content = inserted_content(ctx, text=item.text, run_props=props)
|
|
1070
|
+
if content is not None:
|
|
1071
|
+
p.append(content)
|
|
1072
|
+
target = after if item.slot[0] == "after" else before
|
|
1073
|
+
target.setdefault(item.slot[1], []).append(_serialize(spans, p, item.slot[1]))
|
|
1074
|
+
|
|
1075
|
+
new_data = spans.splice(replacements, insert_after=after, insert_before=before)
|
|
1076
|
+
footprint = Footprint(
|
|
1077
|
+
replaced=frozenset(replacements),
|
|
1078
|
+
inserted_after=frozenset(after),
|
|
1079
|
+
inserted_before=frozenset(before),
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
# The new numbering: each old paragraph, with the inserted ones around it.
|
|
1083
|
+
by_slot: dict[tuple[str, int], list[_NewParagraph]] = {}
|
|
1084
|
+
for item in planner.new:
|
|
1085
|
+
by_slot.setdefault(item.slot, []).append(item)
|
|
1086
|
+
sequence: list[tuple[Paragraph | None, _NewParagraph | None]] = []
|
|
1087
|
+
expected: list[str] = []
|
|
1088
|
+
for para in index.paragraphs:
|
|
1089
|
+
for item in by_slot.get(("before", para.span_index), []):
|
|
1090
|
+
sequence.append((None, item))
|
|
1091
|
+
expected.append(item.expected)
|
|
1092
|
+
sequence.append((para, None))
|
|
1093
|
+
expected.append(_expected_text(para, planner.plans.get(para.number)))
|
|
1094
|
+
for item in by_slot.get(("after", para.span_index), []):
|
|
1095
|
+
sequence.append((None, item))
|
|
1096
|
+
expected.append(item.expected)
|
|
1097
|
+
if not ctx.revision_ids and all(isinstance(e, ApplyStyle | ApplyFormat) for e in edits):
|
|
1098
|
+
raise DocControlError(
|
|
1099
|
+
ErrorCode.SCHEMA_INVALID,
|
|
1100
|
+
"the operations change nothing: the properties are already set",
|
|
1101
|
+
)
|
|
1102
|
+
new_index = _self_check(index, new_data, ctx, expected, sequence, planner)
|
|
1103
|
+
|
|
1104
|
+
address_map: dict[str, str] = {}
|
|
1105
|
+
moved_to: dict[int, int] = {} # old number of a moved paragraph -> number of its copy
|
|
1106
|
+
for number, (_, copy) in enumerate(sequence, start=1):
|
|
1107
|
+
if copy is not None and copy.source is not None:
|
|
1108
|
+
moved_to[copy.source.number] = number
|
|
1109
|
+
for number, (old, _) in enumerate(sequence, start=1):
|
|
1110
|
+
if old is None:
|
|
1111
|
+
continue
|
|
1112
|
+
number = moved_to.get(old.number, number)
|
|
1113
|
+
if old.number != number:
|
|
1114
|
+
address_map[str(old.address)] = str(new_index.paragraph(number).address)
|
|
1115
|
+
result = TrackedResult(
|
|
1116
|
+
applied=len(edits),
|
|
1117
|
+
dry_run=dry_run,
|
|
1118
|
+
preview=planner.previews,
|
|
1119
|
+
address_map=address_map,
|
|
1120
|
+
footprint=[
|
|
1121
|
+
f"p{number}"
|
|
1122
|
+
for number, (old, item) in enumerate(sequence, start=1)
|
|
1123
|
+
if item is not None
|
|
1124
|
+
or (old is not None and (old.number in planner.plans or old.number in annotations))
|
|
1125
|
+
],
|
|
1126
|
+
revision_ids=[int(r) for r in ctx.revision_ids],
|
|
1127
|
+
author=ctx.author,
|
|
1128
|
+
date=ctx.date,
|
|
1129
|
+
before_sha256=pkg.sha256,
|
|
1130
|
+
warnings=_reference_warnings(pkg, scopes),
|
|
1131
|
+
)
|
|
1132
|
+
changes = log_changes(spans, index, replacements, after, before)
|
|
1133
|
+
return PreparedEdits(pkg, new_data, footprint, result, changes)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def log_changes(
|
|
1137
|
+
spans: DocumentSpans,
|
|
1138
|
+
index: DocumentIndex,
|
|
1139
|
+
replacements: Mapping[int, bytes],
|
|
1140
|
+
after: Mapping[int, Sequence[bytes]],
|
|
1141
|
+
before: Mapping[int, Sequence[bytes]],
|
|
1142
|
+
) -> list[dict[str, Any]]:
|
|
1143
|
+
"""The undo-log records of an edit, in document order (see ``safety.history``)."""
|
|
1144
|
+
texts = {para.span_index: para.text for para in index.paragraphs}
|
|
1145
|
+
changes: list[dict[str, Any]] = []
|
|
1146
|
+
for at in sorted(replacements):
|
|
1147
|
+
changes.append(
|
|
1148
|
+
{
|
|
1149
|
+
"part": DOCUMENT_PART,
|
|
1150
|
+
"kind": "replace",
|
|
1151
|
+
"index": at,
|
|
1152
|
+
"before": to_text(spans.paragraph_bytes(at)),
|
|
1153
|
+
"after": to_text(replacements[at]),
|
|
1154
|
+
"text_before": texts[at],
|
|
1155
|
+
"deleted": not has_live_text(replacements[at]),
|
|
1156
|
+
}
|
|
1157
|
+
)
|
|
1158
|
+
for slot, groups in (("before", before), ("after", after)):
|
|
1159
|
+
for at in sorted(groups):
|
|
1160
|
+
changes.extend(
|
|
1161
|
+
{
|
|
1162
|
+
"part": DOCUMENT_PART,
|
|
1163
|
+
"kind": "insert",
|
|
1164
|
+
"slot": slot,
|
|
1165
|
+
"index": at,
|
|
1166
|
+
"after": to_text(data),
|
|
1167
|
+
}
|
|
1168
|
+
for data in groups[at]
|
|
1169
|
+
)
|
|
1170
|
+
return changes
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
#: Request names of edit kinds defined in other modules (``ops.comments`` adds its own).
|
|
1174
|
+
REQUEST_NAMES: dict[type, str] = {}
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def edits_as_request(edits: Sequence[Edit]) -> list[dict[str, Any]]:
|
|
1178
|
+
"""The edits as the ``ops`` objects a batch request carries (for the undo log)."""
|
|
1179
|
+
names: dict[type, str] = {
|
|
1180
|
+
ReplaceText: "text.replace",
|
|
1181
|
+
InsertParagraph: "para.insert",
|
|
1182
|
+
DeleteParagraphs: "para.delete",
|
|
1183
|
+
MoveParagraphs: "para.move",
|
|
1184
|
+
ApplyStyle: "style.apply",
|
|
1185
|
+
ApplyFormat: "format.apply",
|
|
1186
|
+
**REQUEST_NAMES,
|
|
1187
|
+
}
|
|
1188
|
+
out: list[dict[str, Any]] = []
|
|
1189
|
+
for edit in edits:
|
|
1190
|
+
fields = {k: v for k, v in vars(edit).items() if v is not None}
|
|
1191
|
+
if isinstance(edit, ApplyFormat):
|
|
1192
|
+
fields = {"at": edit.at, **edit.props} # the request carries the properties flat
|
|
1193
|
+
out.append({"op": names[type(edit)], **fields})
|
|
1194
|
+
return out
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def apply_edits(
|
|
1198
|
+
doc_path: Path,
|
|
1199
|
+
edits: Sequence[Edit],
|
|
1200
|
+
*,
|
|
1201
|
+
expect_sha256: str | None = None,
|
|
1202
|
+
author: str = DEFAULT_AUTHOR,
|
|
1203
|
+
tag: str | None = None,
|
|
1204
|
+
now: datetime | None = None,
|
|
1205
|
+
dry_run: bool = False,
|
|
1206
|
+
env: Mapping[str, str] | None = None,
|
|
1207
|
+
stage: Callable[[str], None] | None = None,
|
|
1208
|
+
kind: str = "batch",
|
|
1209
|
+
request: Any = None,
|
|
1210
|
+
log_extra: Mapping[str, Any] | None = None,
|
|
1211
|
+
) -> TrackedResult:
|
|
1212
|
+
"""Apply ``edits`` to ``doc_path`` as tracked changes in one verified write.
|
|
1213
|
+
|
|
1214
|
+
Raises ``DOC_NOT_FOUND``, ``NOT_A_DOCX``, ``DOC_LOCKED`` or
|
|
1215
|
+
``DOC_CHANGED_ON_DISK`` (also when ``expect_sha256`` differs),
|
|
1216
|
+
``ADDRESS_INVALID``, ``STALE_ADDRESS``, ``UNSUPPORTED_CONTENT``,
|
|
1217
|
+
``BATCH_CONFLICT`` and ``SCHEMA_INVALID`` before touching the file; later
|
|
1218
|
+
failures are ``VERIFY_FAILED_RESTORED`` (see :func:`write_verified`). Before the
|
|
1219
|
+
write the result is checked: the paragraph texts must be the intended ones
|
|
1220
|
+
and rejecting this call's revisions must give back the original texts, or
|
|
1221
|
+
``INTERNAL`` is raised and nothing is written. A dry run does everything but
|
|
1222
|
+
the write. A real write is appended to the undo log (``kind``, ``request`` and
|
|
1223
|
+
``log_extra`` describe it there); if that append fails the write stands and
|
|
1224
|
+
the result carries a warning instead of ``op_id``.
|
|
1225
|
+
"""
|
|
1226
|
+
if not edits:
|
|
1227
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "no edits given")
|
|
1228
|
+
check_name(author, "author")
|
|
1229
|
+
if tag is not None:
|
|
1230
|
+
check_name(tag, "tag")
|
|
1231
|
+
if not doc_path.is_file():
|
|
1232
|
+
raise DocControlError(
|
|
1233
|
+
ErrorCode.DOC_NOT_FOUND, f"no such file: {doc_path}", path=str(doc_path)
|
|
1234
|
+
)
|
|
1235
|
+
if not dry_run:
|
|
1236
|
+
check_unlocked(doc_path)
|
|
1237
|
+
check_changed(doc_path, expect_sha256)
|
|
1238
|
+
pkg = Package(doc_path)
|
|
1239
|
+
prepared = prepare_edits(pkg, edits, author=author, tag=tag, now=now, dry_run=dry_run)
|
|
1240
|
+
result = prepared.result
|
|
1241
|
+
if dry_run:
|
|
1242
|
+
return result
|
|
1243
|
+
written = write_verified(
|
|
1244
|
+
doc_path,
|
|
1245
|
+
{DOCUMENT_PART: prepared.new_data},
|
|
1246
|
+
prepared.footprint,
|
|
1247
|
+
pkg.sha256,
|
|
1248
|
+
now=now,
|
|
1249
|
+
env=env,
|
|
1250
|
+
stage=stage,
|
|
1251
|
+
)
|
|
1252
|
+
warnings = list(result.warnings)
|
|
1253
|
+
op_id: str | None = None
|
|
1254
|
+
try:
|
|
1255
|
+
op_id = record(
|
|
1256
|
+
doc_path,
|
|
1257
|
+
kind,
|
|
1258
|
+
before_sha256=written.before_sha256,
|
|
1259
|
+
after_sha256=written.after_sha256,
|
|
1260
|
+
backup=written.backup,
|
|
1261
|
+
changes=prepared.changes,
|
|
1262
|
+
request=request if request is not None else edits_as_request(edits),
|
|
1263
|
+
author=result.author,
|
|
1264
|
+
tag=tag,
|
|
1265
|
+
footprint={DOCUMENT_PART: result.footprint},
|
|
1266
|
+
extra=log_extra,
|
|
1267
|
+
now=now,
|
|
1268
|
+
env=env,
|
|
1269
|
+
)
|
|
1270
|
+
except (DocControlError, OSError) as exc:
|
|
1271
|
+
warnings.append(f"HISTORY_NOT_RECORDED: the write succeeded but could not be logged: {exc}")
|
|
1272
|
+
return replace(
|
|
1273
|
+
result,
|
|
1274
|
+
before_sha256=written.before_sha256,
|
|
1275
|
+
after_sha256=written.after_sha256,
|
|
1276
|
+
backup=written.backup,
|
|
1277
|
+
warnings=warnings,
|
|
1278
|
+
op_id=op_id,
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _bookmark_scopes(
|
|
1283
|
+
pkg: Package, index: DocumentIndex, planner: _Planner, ctx: RevisionContext
|
|
1284
|
+
) -> dict[int, BookmarkScope]:
|
|
1285
|
+
"""For each move edit, the checked bookmarks of the paragraphs it moves."""
|
|
1286
|
+
sources: dict[int, list[Paragraph]] = {}
|
|
1287
|
+
for item in planner.new:
|
|
1288
|
+
if item.source is not None:
|
|
1289
|
+
sources.setdefault(item.edit, []).append(item.source)
|
|
1290
|
+
names: dict[str, str] | None = None
|
|
1291
|
+
|
|
1292
|
+
def name_of(bid: str) -> str | None:
|
|
1293
|
+
nonlocal names
|
|
1294
|
+
if names is None:
|
|
1295
|
+
root = ET.fromstring(pkg.read(DOCUMENT_PART))
|
|
1296
|
+
tag = f"{{{W_NS}}}bookmarkStart"
|
|
1297
|
+
names = {
|
|
1298
|
+
e.get(f"{{{W_NS}}}id", ""): e.get(f"{{{W_NS}}}name", "") for e in root.iter(tag)
|
|
1299
|
+
}
|
|
1300
|
+
return names.get(bid)
|
|
1301
|
+
|
|
1302
|
+
return {
|
|
1303
|
+
edit: BookmarkScope(
|
|
1304
|
+
[index.spans.parse_paragraph(p.span_index) for p in paras],
|
|
1305
|
+
[str(p.address) for p in paras],
|
|
1306
|
+
ctx,
|
|
1307
|
+
name_of,
|
|
1308
|
+
)
|
|
1309
|
+
for edit, paras in sources.items()
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _reference_warnings(pkg: Package, scopes: Mapping[int, BookmarkScope]) -> list[str]:
|
|
1314
|
+
"""A warning naming moved bookmarks that fields or links refer to (rejecting breaks them)."""
|
|
1315
|
+
carried = sorted({name for scope in scopes.values() for name in scope.names})
|
|
1316
|
+
if not carried:
|
|
1317
|
+
return []
|
|
1318
|
+
hit = [name for name in carried if name in referenced_bookmarks(pkg)]
|
|
1319
|
+
if not hit:
|
|
1320
|
+
return []
|
|
1321
|
+
return [
|
|
1322
|
+
"moved bookmarks are referenced by fields or links: "
|
|
1323
|
+
+ ", ".join(hit)
|
|
1324
|
+
+ "; rejecting the move removes them and leaves those references broken"
|
|
1325
|
+
]
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _require_no_live_text(elem: ET.Element, para: Paragraph) -> None:
|
|
1329
|
+
"""A deleted paragraph must keep no text outside a deletion, hidden text included."""
|
|
1330
|
+
live = {"t", "tab", "br", "cr", "noBreakHyphen", "softHyphen", "sym", "ptab"}
|
|
1331
|
+
|
|
1332
|
+
def walk(node: ET.Element, deleted: bool) -> bool:
|
|
1333
|
+
for child in node:
|
|
1334
|
+
if not isinstance(child.tag, str):
|
|
1335
|
+
continue
|
|
1336
|
+
local = child.tag.rpartition("}")[2]
|
|
1337
|
+
if local == "pPr":
|
|
1338
|
+
continue
|
|
1339
|
+
if not deleted and local in live:
|
|
1340
|
+
return True
|
|
1341
|
+
if walk(child, deleted or local == "del"):
|
|
1342
|
+
return True
|
|
1343
|
+
return False
|
|
1344
|
+
|
|
1345
|
+
if walk(elem, False):
|
|
1346
|
+
raise DocControlError(
|
|
1347
|
+
ErrorCode.INTERNAL,
|
|
1348
|
+
f"self-check: deleting {para.address} left text outside a deletion",
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _expected_text(para: Paragraph, plan: _ParaPlan | None) -> str:
|
|
1353
|
+
"""The visible text a touched paragraph should have once the edits are in (current state)."""
|
|
1354
|
+
if plan is None:
|
|
1355
|
+
return para.text
|
|
1356
|
+
if plan.delete_all:
|
|
1357
|
+
return ""
|
|
1358
|
+
text = para.text
|
|
1359
|
+
for part in sorted(plan.intervals, key=lambda x: (x.start, x.end), reverse=True):
|
|
1360
|
+
text = text[: part.start] + part.text + text[part.end :]
|
|
1361
|
+
return text
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _formatted(plan: _ParaPlan | None) -> bool:
|
|
1365
|
+
return plan is not None and bool(plan.ppr or plan.formats)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _properties(p: ET.Element) -> tuple[str, list[tuple[str, str]]]:
|
|
1369
|
+
"""The paragraph's own properties and the (run properties, character) of its text."""
|
|
1370
|
+
ppr = p.find(f"{{{W_NS}}}pPr")
|
|
1371
|
+
skip = {f"{{{W_NS}}}{n}" for n in ("rPr", "sectPr", "pPrChange")}
|
|
1372
|
+
para = "".join(
|
|
1373
|
+
ET.tostring(c, "unicode") for c in (ppr if ppr is not None else []) if c.tag not in skip
|
|
1374
|
+
)
|
|
1375
|
+
chars: list[tuple[str, str]] = []
|
|
1376
|
+
for run in p.iter(f"{{{W_NS}}}r"):
|
|
1377
|
+
rpr = run.find(f"{{{W_NS}}}rPr")
|
|
1378
|
+
key = ""
|
|
1379
|
+
if rpr is not None:
|
|
1380
|
+
kept = [c for c in rpr if c.tag != f"{{{W_NS}}}rPrChange"]
|
|
1381
|
+
key = "".join(ET.tostring(c, "unicode") for c in kept)
|
|
1382
|
+
for child in run:
|
|
1383
|
+
local = child.tag.rpartition("}")[2]
|
|
1384
|
+
if local in ("t", "delText"):
|
|
1385
|
+
chars.extend((key, ch) for ch in "".join(child.itertext()))
|
|
1386
|
+
elif local != "rPr":
|
|
1387
|
+
chars.append((key, f"<{local}>"))
|
|
1388
|
+
return para, chars
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _self_check(
|
|
1392
|
+
old: DocumentIndex,
|
|
1393
|
+
new_data: bytes,
|
|
1394
|
+
ctx: RevisionContext,
|
|
1395
|
+
expected: list[str],
|
|
1396
|
+
sequence: list[tuple[Paragraph | None, _NewParagraph | None]],
|
|
1397
|
+
planner: _Planner,
|
|
1398
|
+
) -> DocumentIndex:
|
|
1399
|
+
"""Fail with ``INTERNAL`` unless accepting and rejecting this call's revisions behave.
|
|
1400
|
+
|
|
1401
|
+
Returns the index of the edited document.
|
|
1402
|
+
"""
|
|
1403
|
+
new = build_index(new_data)
|
|
1404
|
+
got = [p.text for p in new.paragraphs]
|
|
1405
|
+
if got != expected:
|
|
1406
|
+
bad = next((n for n, (a, b) in enumerate(zip(got, expected, strict=False), 1) if a != b), 0)
|
|
1407
|
+
raise DocControlError(
|
|
1408
|
+
ErrorCode.INTERNAL,
|
|
1409
|
+
f"self-check: paragraph {bad} of the edited document has unexpected text",
|
|
1410
|
+
paragraph=bad,
|
|
1411
|
+
)
|
|
1412
|
+
ids = set(ctx.revision_ids)
|
|
1413
|
+
touched = {
|
|
1414
|
+
n
|
|
1415
|
+
for n, (para, item) in enumerate(sequence, start=1)
|
|
1416
|
+
if item is not None or (para is not None and para.number in planner.plans)
|
|
1417
|
+
}
|
|
1418
|
+
rejected: dict[int, bytes] = {}
|
|
1419
|
+
joins = [False] * len(new)
|
|
1420
|
+
for number in sorted(touched):
|
|
1421
|
+
para = new.paragraph(number)
|
|
1422
|
+
elem = new.spans.parse_paragraph(para.span_index)
|
|
1423
|
+
joins[number - 1] = any(kind == "ins" and rid in ids for kind, rid in mark_state(elem))
|
|
1424
|
+
reject_revisions(elem, ids)
|
|
1425
|
+
original = sequence[number - 1][0]
|
|
1426
|
+
if original is not None and _formatted(planner.plans.get(original.number)):
|
|
1427
|
+
before = old.spans.parse_paragraph(original.span_index)
|
|
1428
|
+
if _properties(before) != _properties(elem):
|
|
1429
|
+
raise DocControlError(
|
|
1430
|
+
ErrorCode.INTERNAL,
|
|
1431
|
+
f"self-check: rejecting the revisions does not restore the formatting "
|
|
1432
|
+
f"of {original.address}",
|
|
1433
|
+
)
|
|
1434
|
+
rejected[para.span_index] = new.spans.serialize(elem, para.span_index)
|
|
1435
|
+
restored = build_index(new.spans.splice(rejected))
|
|
1436
|
+
merged = _merge_marks([p.text for p in restored.paragraphs], joins)
|
|
1437
|
+
if merged != [p.text for p in old.paragraphs]:
|
|
1438
|
+
raise DocControlError(
|
|
1439
|
+
ErrorCode.INTERNAL, "self-check: rejecting the revisions does not restore the text"
|
|
1440
|
+
)
|
|
1441
|
+
return new
|