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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +80 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/model/__init__.py +1 -0
  7. doc_control/model/address.py +113 -0
  8. doc_control/model/align.py +529 -0
  9. doc_control/model/anchor.py +609 -0
  10. doc_control/model/annotations.py +280 -0
  11. doc_control/model/document.py +109 -0
  12. doc_control/model/format.py +109 -0
  13. doc_control/model/index.py +353 -0
  14. doc_control/model/layout.py +143 -0
  15. doc_control/model/normalized.py +160 -0
  16. doc_control/model/pages.py +403 -0
  17. doc_control/model/parts.py +50 -0
  18. doc_control/model/search.py +403 -0
  19. doc_control/model/segment.py +445 -0
  20. doc_control/model/structure.py +418 -0
  21. doc_control/model/styles.py +134 -0
  22. doc_control/ooxml/__init__.py +1 -0
  23. doc_control/ooxml/commentparts.py +679 -0
  24. doc_control/ooxml/formatting.py +181 -0
  25. doc_control/ooxml/resolve.py +269 -0
  26. doc_control/ooxml/revisions.py +1024 -0
  27. doc_control/ooxml/splice.py +957 -0
  28. doc_control/ooxml/styledef.py +504 -0
  29. doc_control/ooxml/zipio.py +454 -0
  30. doc_control/ops/__init__.py +1 -0
  31. doc_control/ops/anchor.py +135 -0
  32. doc_control/ops/batch.py +177 -0
  33. doc_control/ops/comments.py +433 -0
  34. doc_control/ops/compare.py +620 -0
  35. doc_control/ops/diff.py +177 -0
  36. doc_control/ops/dotx.py +206 -0
  37. doc_control/ops/find.py +190 -0
  38. doc_control/ops/history.py +479 -0
  39. doc_control/ops/layout.py +40 -0
  40. doc_control/ops/locate.py +364 -0
  41. doc_control/ops/meta.py +49 -0
  42. doc_control/ops/page.py +104 -0
  43. doc_control/ops/paging.py +124 -0
  44. doc_control/ops/read.py +517 -0
  45. doc_control/ops/recipe.py +108 -0
  46. doc_control/ops/rename.py +370 -0
  47. doc_control/ops/revision.py +616 -0
  48. doc_control/ops/segment.py +289 -0
  49. doc_control/ops/styleset.py +262 -0
  50. doc_control/ops/stylewrite.py +573 -0
  51. doc_control/ops/text.py +207 -0
  52. doc_control/ops/tracked.py +1441 -0
  53. doc_control/py.typed +0 -0
  54. doc_control/recipes/engine.py +209 -0
  55. doc_control/recipes/schema.py +140 -0
  56. doc_control/recipes/store.py +96 -0
  57. doc_control/registry.py +76 -0
  58. doc_control/safety/__init__.py +38 -0
  59. doc_control/safety/history.py +508 -0
  60. doc_control/safety/lock.py +69 -0
  61. doc_control/safety/store.py +415 -0
  62. doc_control/safety/verify.py +357 -0
  63. doc_control/schema.py +143 -0
  64. doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
  65. doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
  66. doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
  67. doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  68. doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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