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,957 @@
1
+ """Paragraph byte spans and the splice writer for ``word/document.xml``.
2
+
3
+ Writing never re-serialises a whole part. :func:`scan` finds the exact byte
4
+ span ``[start, end)`` of every ``w:p`` in the main body, and :func:`splice`
5
+ replaces, deletes or inserts paragraphs at those spans, copying every other
6
+ byte unchanged: the XML declaration, the root start tag with its namespace
7
+ declarations and ``mc:Ignorable`` list, and all untouched paragraphs.
8
+
9
+ A caller edits one paragraph with ElementTree::
10
+
11
+ spans = scan(data)
12
+ elem = spans.parse_paragraph(i) # an ET element, comments kept
13
+ ...edit elem...
14
+ out = spans.splice({i: spans.serialize(elem, i)})
15
+
16
+ :meth:`DocumentSpans.serialize` writes the element with the document's own
17
+ prefixes (``w:``, ``w14:``…), never ElementTree's ``ns0:``.
18
+
19
+ Namespace declarations inside a paragraph
20
+ ElementTree forgets ``xmlns`` declarations and prefixes, yet Markup
21
+ Compatibility attributes name prefixes in their values
22
+ (``mc:Ignorable="w14"``, ``mc:Choice Requires="wps"``, ``mc:ProcessContent``,
23
+ ``mc:PreserveElements``, ``mc:PreserveAttributes``, ``mc:MustUnderstand``).
24
+ :meth:`DocumentSpans.parse_paragraph` therefore records, for every element
25
+ it builds, the prefix it was written with, the declarations on its start
26
+ tag and the prefixes of its attributes, and :func:`serialize_element` writes
27
+ them back on the same elements. (The record lives in a weak side table
28
+ keyed by element; an element copied with ``copy.deepcopy`` loses it and is
29
+ written with prefixes from the scope.) Every serialised paragraph, and with
30
+ ``check`` every paragraph handed to :meth:`DocumentSpans.splice`, is then
31
+ verified: each prefix named by such an attribute must be declared where it
32
+ is used, or ``DocControlError(INTERNAL)`` is raised and nothing is written.
33
+
34
+ How paragraphs are found
35
+ Only elements named ``p`` in the WordprocessingML namespace count, resolved
36
+ through the ``xmlns`` declarations in scope, so any prefix works (including
37
+ the default namespace and local re-declarations), and a ``p`` from another
38
+ namespace does not. Only paragraphs inside ``w:body`` are reported, in
39
+ document order: direct children, table cells, ``w:sdt`` content and any
40
+ other container. A paragraph inside another paragraph (a text box's
41
+ ``w:txbxContent``) is reported too, with ``depth > 0`` and ``parent`` set
42
+ to the index of the enclosing paragraph; its span lies inside the parent's.
43
+ Comments, CDATA sections and processing instructions are skipped whole, and
44
+ quoted attribute values may contain ``>``.
45
+
46
+ What a splice accepts
47
+ Each replacement (other than ``b""``) and each insertion must be exactly one
48
+ ``w:p`` element, well-formed in the scope where it lands, with nothing
49
+ around it; anything else raises ``DocControlError(SCHEMA_INVALID)``, so a
50
+ value cannot close the body or smuggle in other markup.
51
+
52
+ Limits
53
+ The part must be UTF-8 (Word always writes UTF-8) and must not contain a
54
+ DOCTYPE or entity references other than the five predefined ones (both
55
+ raise ``DocControlError(UNSUPPORTED_CONTENT)``). Malformed markup, invalid
56
+ UTF-8 and invalid character references raise
57
+ ``DocControlError(NOT_A_DOCX)``.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import re
63
+ import weakref
64
+ import xml.etree.ElementTree as ET
65
+ from collections.abc import Iterable, Mapping, Sequence
66
+ from dataclasses import dataclass, field
67
+ from xml.parsers import expat
68
+
69
+ from doc_control.errors import DocControlError, ErrorCode
70
+
71
+ W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
72
+ XML_NS = "http://www.w3.org/XML/1998/namespace"
73
+ MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
74
+
75
+ #: prefix -> namespace URI; ``""`` is the default namespace.
76
+ Scope = Mapping[str, str]
77
+
78
+ _BASE_SCOPE: dict[str, str] = {"xml": XML_NS}
79
+ # Used when a namespace needs a named prefix the document never declared, e.g.
80
+ # a w:-attribute in a document that puts WordprocessingML in the default namespace.
81
+ _CONVENTIONAL_PREFIXES = {W_NS: "w"}
82
+ _NAME = rb"[^\s/>=<]+"
83
+ _START_NAME = re.compile(rb"<(" + _NAME + rb")")
84
+ _ATTR = re.compile(rb"\s+(" + _NAME + rb")\s*=\s*(\"[^\"<]*\"|'[^'<]*')")
85
+ _START_CLOSE = re.compile(rb"\s*(/?)>")
86
+ _END_TAG = re.compile(rb"</(" + _NAME + rb")\s*>")
87
+ _REF = re.compile(r"&(#x[0-9a-fA-F]{1,8}|#[0-9]{1,10}|[A-Za-z_][\w.-]*);")
88
+ _ENTITIES = {"lt": "<", "gt": ">", "amp": "&", "quot": '"', "apos": "'"}
89
+ _WRAPPER = "doc-control-wrapper"
90
+ # MCE attributes whose values list prefixes, and those that list prefix:local names.
91
+ _MCE_PREFIX_LISTS = frozenset({"Ignorable", "MustUnderstand"})
92
+ _MCE_QNAME_LISTS = frozenset({"ProcessContent", "PreserveElements", "PreserveAttributes"})
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ParagraphSpan:
97
+ """One ``w:p`` element: ``data[start:end]`` is its exact markup.
98
+
99
+ ``outer_scope`` is the namespace scope around the element; ``own_decls``
100
+ holds the ``xmlns`` declarations written on its own start tag, in order.
101
+ """
102
+
103
+ index: int
104
+ start: int
105
+ end: int
106
+ depth: int
107
+ parent: int | None
108
+ outer_scope: Scope
109
+ own_decls: tuple[tuple[str, str], ...]
110
+
111
+ @property
112
+ def nested(self) -> bool:
113
+ """True for a paragraph inside another paragraph (e.g. in a text box)."""
114
+ return self.depth > 0
115
+
116
+ @property
117
+ def scope(self) -> dict[str, str]:
118
+ """The namespace scope inside the paragraph's start tag."""
119
+ out = dict(self.outer_scope)
120
+ out.update(self.own_decls)
121
+ return out
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class DocumentSpans:
126
+ """The paragraph spans of one ``word/document.xml``.
127
+
128
+ ``root_start``/``root_end`` bound the root start tag; ``body_content``
129
+ is the offset just after the ``w:body`` start tag (where an insertion
130
+ with key ``-1`` lands) and ``body_scope`` the namespace scope there.
131
+ """
132
+
133
+ data: bytes = field(repr=False)
134
+ paragraphs: tuple[ParagraphSpan, ...]
135
+ root_start: int
136
+ root_end: int
137
+ root_scope: Scope
138
+ body_content: int | None
139
+ known_prefixes: Mapping[str, str] # uri -> first prefix declared for it anywhere
140
+ body_scope: Scope | None = None
141
+
142
+ # -- reading ---------------------------------------------------------
143
+
144
+ def paragraph_bytes(self, index: int) -> bytes:
145
+ p = self.paragraphs[index]
146
+ return self.data[p.start : p.end]
147
+
148
+ def parse_paragraph(self, index: int) -> ET.Element:
149
+ """Parse paragraph ``index`` into an ElementTree element.
150
+
151
+ Comments and processing instructions inside it are kept as
152
+ ``ET.Comment``/``ET.ProcessingInstruction`` nodes so that
153
+ :meth:`serialize` writes them back. The element's tail is empty. Each
154
+ element's original prefix and ``xmlns`` declarations are recorded for
155
+ :meth:`serialize` (see the module docstring).
156
+
157
+ Raises ``DocControlError``: ``UNSUPPORTED_CONTENT`` for an entity
158
+ reference other than the predefined five, ``NOT_A_DOCX`` for any other
159
+ malformed content (bad character reference, duplicate attribute…).
160
+ """
161
+ p = self.paragraphs[index]
162
+ reader = _FragmentReader(p.outer_scope, build=True)
163
+ try:
164
+ root = reader.read(self.data[p.start : p.end])
165
+ except expat.ExpatError as exc:
166
+ if exc.code == expat.errors.codes[expat.errors.XML_ERROR_UNDEFINED_ENTITY]:
167
+ raise DocControlError(
168
+ ErrorCode.UNSUPPORTED_CONTENT,
169
+ f"paragraph {index} uses an undefined entity: {exc}",
170
+ ) from exc
171
+ raise _malformed(f"paragraph {index}: {exc}", p.start) from exc
172
+ except _BadFragment as exc:
173
+ raise _malformed(f"paragraph {index}: {exc}", p.start) from exc
174
+ assert root is not None
175
+ elem = root[0]
176
+ elem.tail = None
177
+ return elem
178
+
179
+ # -- writing ---------------------------------------------------------
180
+
181
+ def serialize(self, elem: ET.Element, at: int | None = None) -> bytes:
182
+ """Serialise ``elem`` for splicing at paragraph ``at``.
183
+
184
+ With ``at`` the element is written for the scope of that paragraph
185
+ (its own ``xmlns`` declarations are written back on the start tag);
186
+ with ``at=None`` (a paragraph for the body level) the scope just inside
187
+ ``w:body`` is used. Elements from :meth:`parse_paragraph` keep their
188
+ original prefixes and declarations. Otherwise namespaces already in
189
+ scope keep the document's prefixes, and a namespace that is not in
190
+ scope is declared on ``elem`` itself, under the prefix the document uses
191
+ for it elsewhere when that is free (``w`` for WordprocessingML),
192
+ otherwise ``ns0``, ``ns1``… Raises ``DocControlError(INTERNAL)`` when
193
+ the result would not be well-formed or would leave an MCE attribute
194
+ naming an undeclared prefix.
195
+ """
196
+ if at is None:
197
+ scope = self.body_scope if self.body_scope is not None else self.root_scope
198
+ return serialize_element(elem, scope, (), self.known_prefixes)
199
+ p = self.paragraphs[at]
200
+ return serialize_element(elem, p.outer_scope, p.own_decls, self.known_prefixes)
201
+
202
+ def splice(
203
+ self,
204
+ replacements: Mapping[int, bytes] | None = None,
205
+ *,
206
+ insert_after: Mapping[int, Sequence[bytes]] | None = None,
207
+ delete: Iterable[int] = (),
208
+ check: bool = True,
209
+ insert_before: Mapping[int, Sequence[bytes]] | None = None,
210
+ ) -> bytes:
211
+ """Return the document with paragraph operations applied.
212
+
213
+ - ``replacements[i]`` replaces paragraph ``i``'s bytes with one paragraph
214
+ (``b""`` deletes it; use ``insert_after`` to add more).
215
+ - ``insert_after[i]`` inserts the given paragraphs right after paragraph
216
+ ``i`` (after its replacement, if any; also allowed when ``i`` is
217
+ deleted). Key ``-1`` inserts at the very start of the body content,
218
+ at body level, before any paragraph, table or ``w:sdt``.
219
+ - ``insert_before[i]`` inserts the given paragraphs right before paragraph
220
+ ``i`` (before its replacement, if any), in the container of ``i``. Where
221
+ one offset is both the end of paragraph ``j`` and the start of ``i``,
222
+ the ``insert_after[j]`` paragraphs come first.
223
+ - ``delete`` removes paragraphs.
224
+
225
+ Indices are those of :attr:`paragraphs` in this (original) document.
226
+ Every byte outside the touched spans is copied unchanged. Every
227
+ replacement and insertion must be exactly one ``w:p`` element that is
228
+ well-formed in the scope where it lands, or ``SCHEMA_INVALID`` is
229
+ raised. Operations that overlap — the same index replaced and deleted,
230
+ or a nested paragraph touched together with its enclosing one — raise
231
+ ``BATCH_CONFLICT``. An unknown index raises ``IndexError``, a non-int
232
+ index (``bool`` included) ``TypeError``. With ``check`` (the default)
233
+ every new paragraph must leave no MCE attribute naming an undeclared
234
+ prefix and the result must parse with ElementTree, or ``INTERNAL`` is
235
+ raised and nothing is returned.
236
+ """
237
+ replacements = dict(replacements or {})
238
+ inserts = {i: list(paras) for i, paras in (insert_after or {}).items()}
239
+ befores = {i: list(paras) for i, paras in (insert_before or {}).items()}
240
+ deletes = set(delete)
241
+ n = len(self.paragraphs)
242
+ for i in [*replacements, *deletes, *inserts, *befores]:
243
+ if isinstance(i, bool) or not isinstance(i, int):
244
+ raise TypeError(f"paragraph index must be an int, not {type(i).__name__}")
245
+ if not (0 <= i < n or (i == -1 and i in inserts and i not in befores)):
246
+ raise IndexError(f"no paragraph {i} (document has {n})")
247
+
248
+ both = deletes & replacements.keys()
249
+ if both:
250
+ raise _conflict(f"paragraphs both replaced and deleted: {sorted(both)}")
251
+ touched = deletes | replacements.keys()
252
+ for i in sorted(touched | inserts.keys() | befores.keys()):
253
+ if i < 0:
254
+ continue
255
+ anc = self.paragraphs[i].parent
256
+ while anc is not None:
257
+ if anc in touched:
258
+ raise _conflict(f"paragraph {i} lies inside paragraph {anc}, also edited")
259
+ anc = self.paragraphs[anc].parent
260
+
261
+ mce_problems: list[str] = []
262
+ for i, new in replacements.items():
263
+ if new != b"":
264
+ found = _validate_fragment(new, self.paragraphs[i].outer_scope)
265
+ mce_problems += found or []
266
+ for i, paras in inserts.items():
267
+ scope = self._insert_scope(i)
268
+ for new in paras:
269
+ found = _validate_fragment(new, scope)
270
+ mce_problems += found or []
271
+ for i, paras in befores.items():
272
+ for new in paras:
273
+ found = _validate_fragment(new, self.paragraphs[i].outer_scope)
274
+ mce_problems += found or []
275
+ if check and mce_problems:
276
+ raise DocControlError(
277
+ ErrorCode.INTERNAL,
278
+ "new paragraph names an undeclared prefix: " + "; ".join(mce_problems),
279
+ )
280
+
281
+ # (start, end, new bytes), applied in offset order. Spans are disjoint,
282
+ # so the only ties are a zero-width insertion at the offset where a
283
+ # replaced paragraph starts (the previous paragraph's end, or the body
284
+ # content start for ``-1``): sorting by (start, end) puts the insertion
285
+ # first, i.e. before it.
286
+ ops: list[tuple[int, int, bytes]] = []
287
+ for i in touched:
288
+ p = self.paragraphs[i]
289
+ ops.append((p.start, p.end, replacements.get(i, b"")))
290
+ for i, paras in inserts.items():
291
+ if i == -1:
292
+ pos = self.body_content
293
+ assert pos is not None # _insert_scope refused a missing body
294
+ else:
295
+ pos = self.paragraphs[i].end
296
+ ops.append((pos, pos, b"".join(paras)))
297
+ for i, paras in befores.items():
298
+ pos = self.paragraphs[i].start
299
+ ops.append((pos, pos, b"".join(paras)))
300
+ ops.sort(key=lambda op: (op[0], op[1]))
301
+
302
+ out = bytearray()
303
+ cursor = 0
304
+ for start, end, new in ops:
305
+ out += self.data[cursor:start]
306
+ out += new
307
+ cursor = end
308
+ out += self.data[cursor:]
309
+ result = bytes(out)
310
+ if check and result != self.data:
311
+ try:
312
+ ET.fromstring(result)
313
+ except ET.ParseError as exc:
314
+ raise DocControlError(
315
+ ErrorCode.INTERNAL, f"spliced document is not well-formed: {exc}"
316
+ ) from exc
317
+ return result
318
+
319
+ def _insert_scope(self, i: int) -> Scope:
320
+ if i != -1:
321
+ return self.paragraphs[i].outer_scope
322
+ if self.body_content is None or self.body_scope is None:
323
+ raise DocControlError(
324
+ ErrorCode.UNSUPPORTED_CONTENT, "document has no w:body to insert into"
325
+ )
326
+ return self.body_scope
327
+
328
+
329
+ def splice(
330
+ original: bytes,
331
+ replacements: Mapping[int, bytes] | None = None,
332
+ *,
333
+ insert_after: Mapping[int, Sequence[bytes]] | None = None,
334
+ delete: Iterable[int] = (),
335
+ check: bool = True,
336
+ insert_before: Mapping[int, Sequence[bytes]] | None = None,
337
+ ) -> bytes:
338
+ """Scan ``original`` and apply :meth:`DocumentSpans.splice` to it."""
339
+ return scan(original).splice(
340
+ replacements,
341
+ insert_after=insert_after,
342
+ delete=delete,
343
+ check=check,
344
+ insert_before=insert_before,
345
+ )
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # Tokenizer
350
+
351
+
352
+ def _malformed(msg: str, pos: int) -> DocControlError:
353
+ return DocControlError(
354
+ ErrorCode.NOT_A_DOCX, f"word/document.xml: {msg} at byte {pos}", offset=pos
355
+ )
356
+
357
+
358
+ def _conflict(msg: str) -> DocControlError:
359
+ return DocControlError(ErrorCode.BATCH_CONFLICT, msg)
360
+
361
+
362
+ def _is_xml_char(code: int) -> bool:
363
+ return (
364
+ code in (0x9, 0xA, 0xD)
365
+ or 0x20 <= code <= 0xD7FF
366
+ or 0xE000 <= code <= 0xFFFD
367
+ or 0x10000 <= code <= 0x10FFFF
368
+ )
369
+
370
+
371
+ def _unescape(raw: str, pos: int) -> str:
372
+ """Resolve the references in an attribute value, as an XML parser would."""
373
+
374
+ def sub(m: re.Match[str]) -> str:
375
+ ref = m.group(1)
376
+ if ref.startswith("#"):
377
+ code = int(ref[2:], 16) if ref.startswith("#x") else int(ref[1:])
378
+ if not _is_xml_char(code):
379
+ raise _malformed(f"invalid character reference &{ref};", pos)
380
+ return chr(code)
381
+ if ref in _ENTITIES:
382
+ return _ENTITIES[ref]
383
+ raise DocControlError(
384
+ ErrorCode.UNSUPPORTED_CONTENT,
385
+ f"word/document.xml uses the undefined entity &{ref}; at byte {pos}",
386
+ offset=pos,
387
+ )
388
+
389
+ if "&" in _REF.sub("", raw):
390
+ raise _malformed("'&' that starts no valid reference", pos)
391
+ return _REF.sub(sub, raw)
392
+
393
+
394
+ def _resolve(qname: bytes, scope: Scope, pos: int) -> tuple[str, str]:
395
+ name = qname.decode("utf-8")
396
+ prefix, sep, local = name.rpartition(":")
397
+ if sep and not prefix:
398
+ raise _malformed(f"bad name {name!r}", pos)
399
+ uri = scope.get(prefix) if prefix else scope.get("", "")
400
+ if uri is None:
401
+ raise _malformed(f"undeclared prefix {prefix!r}", pos)
402
+ return uri, local
403
+
404
+
405
+ @dataclass
406
+ class _Open:
407
+ qname: bytes
408
+ scope: Scope
409
+ para: int | None
410
+ body: bool
411
+
412
+
413
+ def scan(data: bytes) -> DocumentSpans:
414
+ """Tokenize ``data`` and return the span of every body paragraph."""
415
+ if data.startswith((b"\xff\xfe", b"\xfe\xff")):
416
+ raise DocControlError(ErrorCode.UNSUPPORTED_CONTENT, "word/document.xml is not UTF-8")
417
+ try:
418
+ data.decode("utf-8")
419
+ except UnicodeDecodeError as exc:
420
+ raise _malformed("invalid UTF-8", exc.start) from exc
421
+ starts: list[int] = []
422
+ ends: list[int] = []
423
+ depths: list[int] = []
424
+ parents: list[int | None] = []
425
+ outer_scopes: list[Scope] = []
426
+ own: list[tuple[tuple[str, str], ...]] = []
427
+ known: dict[str, str] = {}
428
+
429
+ stack: list[_Open] = []
430
+ para_stack: list[int] = []
431
+ body_depth = 0
432
+ root: tuple[int, int, Scope, str, str] | None = None
433
+ body_content: int | None = None
434
+ body_scope: Scope | None = None
435
+ root_closed = False
436
+ pos = 0
437
+ n = len(data)
438
+ while True:
439
+ lt = data.find(b"<", pos)
440
+ if lt < 0:
441
+ break
442
+ if data.startswith(b"<!--", lt):
443
+ close = data.find(b"-->", lt + 4)
444
+ if close < 0:
445
+ raise _malformed("unterminated comment", lt)
446
+ pos = close + 3
447
+ continue
448
+ if data.startswith(b"<![CDATA[", lt):
449
+ close = data.find(b"]]>", lt + 9)
450
+ if close < 0:
451
+ raise _malformed("unterminated CDATA section", lt)
452
+ pos = close + 3
453
+ continue
454
+ if data.startswith(b"<?", lt):
455
+ close = data.find(b"?>", lt + 2)
456
+ if close < 0:
457
+ raise _malformed("unterminated processing instruction", lt)
458
+ pos = close + 2
459
+ continue
460
+ if data.startswith(b"<!", lt):
461
+ raise DocControlError(
462
+ ErrorCode.UNSUPPORTED_CONTENT, "word/document.xml has a DOCTYPE or declaration"
463
+ )
464
+ if data.startswith(b"</", lt):
465
+ m = _END_TAG.match(data, lt)
466
+ if m is None:
467
+ raise _malformed("bad end tag", lt)
468
+ if not stack or stack[-1].qname != m.group(1):
469
+ raise _malformed(f"unexpected end tag {m.group(1)!r}", lt)
470
+ top = stack.pop()
471
+ pos = m.end()
472
+ if top.para is not None:
473
+ ends[top.para] = pos
474
+ para_stack.pop()
475
+ if top.body:
476
+ body_depth -= 1
477
+ if not stack:
478
+ root_closed = True
479
+ continue
480
+
481
+ # Start tag.
482
+ if root_closed:
483
+ raise _malformed("content after the root element", lt)
484
+ m = _START_NAME.match(data, lt)
485
+ if m is None:
486
+ raise _malformed("bad start tag", lt)
487
+ qname = m.group(1)
488
+ cur = m.end()
489
+ attrs: list[tuple[bytes, bytes]] = []
490
+ while True:
491
+ am = _ATTR.match(data, cur)
492
+ if am is None:
493
+ break
494
+ attrs.append((am.group(1), am.group(2)[1:-1]))
495
+ cur = am.end()
496
+ cm = _START_CLOSE.match(data, cur)
497
+ if cm is None:
498
+ raise _malformed("bad attribute or unterminated start tag", cur)
499
+ end = cm.end()
500
+ self_closing = bool(cm.group(1))
501
+
502
+ parent_scope: Scope = stack[-1].scope if stack else _BASE_SCOPE
503
+ decls: list[tuple[str, str]] = []
504
+ for aname, aval in attrs:
505
+ if aname == b"xmlns" or aname.startswith(b"xmlns:"):
506
+ prefix = aname[6:].decode("utf-8")
507
+ uri = _unescape(aval.decode("utf-8"), lt)
508
+ if prefix and not uri:
509
+ raise _malformed(f"empty namespace for prefix {prefix!r}", lt)
510
+ decls.append((prefix, uri))
511
+ if prefix:
512
+ known.setdefault(uri, prefix)
513
+ scope: Scope = parent_scope
514
+ if decls:
515
+ new_scope = dict(parent_scope)
516
+ new_scope.update(decls)
517
+ scope = new_scope
518
+ uri, local = _resolve(qname, scope, lt)
519
+
520
+ para: int | None = None
521
+ is_body = False
522
+ if root is None:
523
+ root = (lt, end, scope, uri, local)
524
+ elif uri == W_NS:
525
+ if local == "body" and body_depth == 0 and len(stack) == 1:
526
+ is_body = True
527
+ body_content = end
528
+ body_scope = scope
529
+ elif local == "p" and body_depth > 0:
530
+ para = len(starts)
531
+ starts.append(lt)
532
+ ends.append(end)
533
+ depths.append(len(para_stack))
534
+ parents.append(para_stack[-1] if para_stack else None)
535
+ outer_scopes.append(parent_scope)
536
+ own.append(tuple(decls))
537
+ if self_closing:
538
+ if is_body:
539
+ body_content = None
540
+ if not stack:
541
+ root_closed = True
542
+ else:
543
+ stack.append(_Open(qname, scope, para, is_body))
544
+ if para is not None:
545
+ para_stack.append(para)
546
+ if is_body:
547
+ body_depth += 1
548
+ pos = end
549
+
550
+ if root is None:
551
+ raise _malformed("no root element", 0)
552
+ if stack:
553
+ raise _malformed(f"unclosed element {stack[-1].qname!r}", n)
554
+ root_start, root_end, root_scope, root_uri, root_local = root
555
+ if (root_uri, root_local) != (W_NS, "document"):
556
+ raise DocControlError(
557
+ ErrorCode.NOT_A_DOCX, "word/document.xml root is not a WordprocessingML w:document"
558
+ )
559
+ paragraphs = tuple(
560
+ ParagraphSpan(i, starts[i], ends[i], depths[i], parents[i], outer_scopes[i], own[i])
561
+ for i in range(len(starts))
562
+ )
563
+ return DocumentSpans(
564
+ data=data,
565
+ paragraphs=paragraphs,
566
+ root_start=root_start,
567
+ root_end=root_end,
568
+ root_scope=root_scope,
569
+ body_content=body_content,
570
+ known_prefixes=known,
571
+ body_scope=body_scope,
572
+ )
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # Fragment reader: expat without namespace processing, so prefixes and
577
+ # declarations stay visible; names are resolved here, against the scope where
578
+ # the fragment sits.
579
+
580
+
581
+ @dataclass(frozen=True)
582
+ class _Origin:
583
+ """How an element parsed by :meth:`DocumentSpans.parse_paragraph` was written."""
584
+
585
+ prefix: str # prefix of its name ("" when unprefixed)
586
+ decls: tuple[tuple[str, str], ...] # xmlns declarations on its start tag, in order
587
+ attr_prefixes: Mapping[str, str] # Clark attribute name -> prefix it was written with
588
+
589
+
590
+ _ORIGINS: weakref.WeakKeyDictionary[ET.Element, _Origin] = weakref.WeakKeyDictionary()
591
+
592
+
593
+ class _BadFragment(ValueError):
594
+ """A fragment that is well-formed XML but not namespace-well-formed."""
595
+
596
+
597
+ def _resolve_name(qname: str, scope: Scope, *, element: bool) -> tuple[str, str, str]:
598
+ """Return ``(uri, local, prefix)`` for a raw element or attribute name."""
599
+ prefix, sep, local = qname.partition(":")
600
+ if not sep:
601
+ return (scope.get("", "") if element else ""), qname, ""
602
+ if not prefix or not local or ":" in local:
603
+ raise _BadFragment(f"bad name {qname!r}")
604
+ uri = scope.get(prefix)
605
+ if uri is None:
606
+ raise _BadFragment(f"undeclared prefix {prefix!r}")
607
+ return uri, local, prefix
608
+
609
+
610
+ def _clark(uri: str, local: str) -> str:
611
+ return f"{{{uri}}}{local}" if uri else local
612
+
613
+
614
+ class _FragmentReader:
615
+ """Reads markup that sits in ``scope``; optionally builds ElementTree nodes.
616
+
617
+ ``top`` lists the resolved names of the fragment's top-level elements,
618
+ ``stray`` is set by any text, comment or processing instruction beside them,
619
+ and ``mce_problems`` describes every MCE attribute value that names a
620
+ prefix not declared where it is used.
621
+ """
622
+
623
+ def __init__(self, scope: Scope, *, build: bool) -> None:
624
+ self._scopes: list[Scope] = [{**_BASE_SCOPE, **scope}]
625
+ self._tags: list[str] = []
626
+ self._depth = 0
627
+ self._builder = ET.TreeBuilder(insert_comments=True, insert_pis=True) if build else None
628
+ self.top: list[tuple[str, str]] = []
629
+ self.stray = False
630
+ self.mce_problems: list[str] = []
631
+
632
+ def read(self, fragment: bytes) -> ET.Element | None:
633
+ parser = expat.ParserCreate()
634
+ parser.ordered_attributes = True
635
+ parser.StartElementHandler = self._start
636
+ parser.EndElementHandler = self._end
637
+ parser.CharacterDataHandler = self._data
638
+ parser.CommentHandler = self._comment
639
+ parser.ProcessingInstructionHandler = self._pi
640
+ parser.Parse(f"<{_WRAPPER}>".encode(), False)
641
+ parser.Parse(fragment, False)
642
+ parser.Parse(f"</{_WRAPPER}>".encode(), True)
643
+ return self._builder.close() if self._builder is not None else None
644
+
645
+ def _start(self, name: str, flat: list[str]) -> None:
646
+ if self._depth == 0:
647
+ self._depth = 1
648
+ if self._builder is not None:
649
+ self._builder.start(_WRAPPER, {})
650
+ return
651
+ decls: list[tuple[str, str]] = []
652
+ plain: list[tuple[str, str]] = []
653
+ for k, v in zip(flat[::2], flat[1::2], strict=True):
654
+ if k == "xmlns":
655
+ decls.append(("", v))
656
+ elif k.startswith("xmlns:"):
657
+ prefix = k[6:]
658
+ if not prefix or ":" in prefix or not v or prefix == "xmlns":
659
+ raise _BadFragment(f"bad namespace declaration {k}={v!r}")
660
+ decls.append((prefix, v))
661
+ else:
662
+ plain.append((k, v))
663
+ scope = self._scopes[-1]
664
+ if decls:
665
+ scope = {**scope, **dict(decls)}
666
+ uri, local, prefix = _resolve_name(name, scope, element=True)
667
+ attrib: dict[str, str] = {}
668
+ attr_prefixes: dict[str, str] = {}
669
+ for k, v in plain:
670
+ auri, alocal, aprefix = _resolve_name(k, scope, element=False)
671
+ key = _clark(auri, alocal)
672
+ if key in attrib:
673
+ raise _BadFragment(f"duplicate attribute {key!r} on {name!r}")
674
+ attrib[key] = v
675
+ if aprefix:
676
+ attr_prefixes[key] = aprefix
677
+ self._check_mce(name, uri, local, k, auri, alocal, v, scope)
678
+ if self._depth == 1:
679
+ self.top.append((uri, local))
680
+ self._depth += 1
681
+ self._scopes.append(scope)
682
+ tag = _clark(uri, local)
683
+ self._tags.append(tag)
684
+ if self._builder is not None:
685
+ elem = self._builder.start(tag, attrib)
686
+ _ORIGINS[elem] = _Origin(prefix, tuple(decls), attr_prefixes)
687
+
688
+ def _check_mce(
689
+ self,
690
+ elem_name: str,
691
+ uri: str,
692
+ local: str,
693
+ attr_name: str,
694
+ auri: str,
695
+ alocal: str,
696
+ value: str,
697
+ scope: Scope,
698
+ ) -> None:
699
+ if auri == MC_NS and alocal in _MCE_PREFIX_LISTS:
700
+ prefixes = value.split()
701
+ elif auri == MC_NS and alocal in _MCE_QNAME_LISTS:
702
+ prefixes = [tok.partition(":")[0] for tok in value.split() if ":" in tok]
703
+ elif (uri, local, attr_name) == (MC_NS, "Choice", "Requires"):
704
+ prefixes = value.split()
705
+ else:
706
+ return
707
+ missing = [p for p in prefixes if p not in scope or p == ""]
708
+ if missing:
709
+ self.mce_problems.append(
710
+ f"{attr_name}={value!r} on <{elem_name}> names undeclared {missing}"
711
+ )
712
+
713
+ def _end(self, name: str) -> None:
714
+ self._depth -= 1
715
+ if self._depth == 0:
716
+ if self._builder is not None:
717
+ self._builder.end(_WRAPPER)
718
+ return
719
+ self._scopes.pop()
720
+ tag = self._tags.pop()
721
+ if self._builder is not None:
722
+ self._builder.end(tag)
723
+
724
+ def _data(self, text: str) -> None:
725
+ if self._depth == 1:
726
+ self.stray = True
727
+ if self._builder is not None:
728
+ self._builder.data(text)
729
+
730
+ def _comment(self, text: str) -> None:
731
+ if self._depth == 1:
732
+ self.stray = True
733
+ if self._builder is not None:
734
+ self._builder.comment(text)
735
+
736
+ def _pi(self, target: str, data: str) -> None:
737
+ if self._depth == 1:
738
+ self.stray = True
739
+ if self._builder is not None:
740
+ self._builder.pi(target, data)
741
+
742
+
743
+ def _validate_fragment(fragment: bytes, scope: Scope) -> list[str]:
744
+ """Check that ``fragment`` is exactly one ``w:p`` in ``scope``.
745
+
746
+ Returns the MCE prefix problems found in it (empty when there are none);
747
+ raises ``SCHEMA_INVALID`` when it is not a single well-formed paragraph.
748
+ """
749
+ if not isinstance(fragment, bytes | bytearray):
750
+ raise DocControlError(
751
+ ErrorCode.SCHEMA_INVALID, f"paragraph must be bytes, not {type(fragment).__name__}"
752
+ )
753
+ reader = _FragmentReader(scope, build=False)
754
+ try:
755
+ reader.read(bytes(fragment))
756
+ except (expat.ExpatError, _BadFragment) as exc:
757
+ raise DocControlError(
758
+ ErrorCode.SCHEMA_INVALID, f"new paragraph is not well-formed: {exc}"
759
+ ) from exc
760
+ if reader.top != [(W_NS, "p")] or reader.stray:
761
+ raise DocControlError(
762
+ ErrorCode.SCHEMA_INVALID,
763
+ "each replacement or insertion must be exactly one w:p element and nothing else",
764
+ )
765
+ return reader.mce_problems
766
+
767
+
768
+ def _mce_problems(fragment: bytes, scope: Scope) -> list[str]:
769
+ """Return the MCE attributes in ``fragment`` that name prefixes undeclared in scope.
770
+
771
+ ``scope`` is the namespace scope where ``fragment`` sits. Raises
772
+ ``DocControlError(INTERNAL)`` when the fragment is not well-formed there.
773
+ """
774
+ reader = _FragmentReader(scope, build=False)
775
+ try:
776
+ reader.read(fragment)
777
+ except (expat.ExpatError, _BadFragment) as exc:
778
+ raise DocControlError(
779
+ ErrorCode.INTERNAL, f"serialised paragraph is not well-formed: {exc}"
780
+ ) from exc
781
+ return reader.mce_problems
782
+
783
+
784
+ # ---------------------------------------------------------------------------
785
+ # Serialiser
786
+
787
+
788
+ def _escape_text(s: str) -> str:
789
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\r", "&#13;")
790
+
791
+
792
+ def _escape_attr(s: str) -> str:
793
+ return (
794
+ s.replace("&", "&amp;")
795
+ .replace("<", "&lt;")
796
+ .replace(">", "&gt;")
797
+ .replace('"', "&quot;")
798
+ .replace("\t", "&#9;")
799
+ .replace("\n", "&#10;")
800
+ .replace("\r", "&#13;")
801
+ )
802
+
803
+
804
+ def _split(name: str) -> tuple[str, str]:
805
+ if name.startswith("{"):
806
+ uri, _, local = name[1:].partition("}")
807
+ return uri, local
808
+ return "", name
809
+
810
+
811
+ def serialize_element(
812
+ elem: ET.Element,
813
+ outer_scope: Scope,
814
+ own_decls: Sequence[tuple[str, str]] = (),
815
+ known_prefixes: Mapping[str, str] | None = None,
816
+ ) -> bytes:
817
+ """Serialise ``elem`` (and its subtree, without its tail) as UTF-8 bytes.
818
+
819
+ ``outer_scope`` is the namespace scope at the place the bytes will go;
820
+ ``own_decls`` are written as ``xmlns`` declarations on ``elem`` (as the
821
+ original start tag had them). Elements built by
822
+ :meth:`DocumentSpans.parse_paragraph` get their recorded declarations back
823
+ on the same elements and keep their prefixes while those still resolve to
824
+ the same namespace. Other names use a prefix already bound to their
825
+ namespace; namespaces not in scope are declared on ``elem`` (or, when an
826
+ inner declaration shadows that prefix, on the element that needs it),
827
+ preferring ``known_prefixes[uri]`` when that prefix is free.
828
+
829
+ Raises ``ValueError`` for a no-namespace element while a default namespace
830
+ is in effect, which cannot be written without redefining it, and
831
+ ``DocControlError(INTERNAL)`` when the output is not well-formed in
832
+ ``outer_scope`` or leaves an MCE attribute naming an undeclared prefix.
833
+ """
834
+ known = known_prefixes or {}
835
+ top_origin = _ORIGINS.get(elem)
836
+ top_decls: list[tuple[str, str]] = list(own_decls)
837
+ if top_origin is not None:
838
+ have = {p for p, _ in top_decls}
839
+ top_decls += [d for d in top_origin.decls if d[0] not in have]
840
+ top_scope: dict[str, str] = {**_BASE_SCOPE, **outer_scope, **dict(top_decls)}
841
+ generated: set[str] = set()
842
+
843
+ def pick(uri: str, scope: Mapping[str, str], named: bool) -> str | None:
844
+ """A prefix bound to ``uri``: the document's own named ones first."""
845
+ found: str | None = None
846
+ default = False
847
+ for p, u in scope.items():
848
+ if u != uri:
849
+ continue
850
+ if not p:
851
+ default = True
852
+ elif p not in generated or found is None:
853
+ found = p
854
+ if found is not None and found not in generated:
855
+ return found
856
+ if default and not named:
857
+ return ""
858
+ return found
859
+
860
+ def fresh(uri: str, scope: Mapping[str, str]) -> str:
861
+ want = known.get(uri) or _CONVENTIONAL_PREFIXES.get(uri)
862
+ if want and want not in scope and want != "xmlns":
863
+ return want
864
+ k = 0
865
+ while f"ns{k}" in scope:
866
+ k += 1
867
+ return f"ns{k}"
868
+
869
+ # First pass: namespaces the subtree uses that nothing in scope binds get
870
+ # declared on elem itself, as before.
871
+ missing: list[str] = []
872
+
873
+ def need(node: ET.Element, scope: Mapping[str, str]) -> None:
874
+ if not isinstance(node.tag, str):
875
+ return
876
+ origin = _ORIGINS.get(node) if node is not elem else None
877
+ if origin is not None and origin.decls:
878
+ scope = {**scope, **dict(origin.decls)}
879
+ uri, _ = _split(node.tag)
880
+ if uri and pick(uri, scope, False) is None and uri not in missing:
881
+ missing.append(uri)
882
+ for name in node.attrib:
883
+ auri, _ = _split(name)
884
+ if auri and pick(auri, scope, True) is None and auri not in missing:
885
+ missing.append(auri)
886
+ for child in node:
887
+ need(child, scope)
888
+
889
+ need(elem, top_scope)
890
+ for uri in missing:
891
+ prefix = fresh(uri, top_scope)
892
+ top_scope[prefix] = uri
893
+ top_decls.append((prefix, uri))
894
+ generated.add(prefix)
895
+
896
+ parts: list[str] = []
897
+
898
+ def write(node: ET.Element, scope: Mapping[str, str], decls: list[tuple[str, str]]) -> None:
899
+ if node.tag is ET.Comment:
900
+ parts.append(f"<!--{node.text or ''}-->")
901
+ elif node.tag is ET.ProcessingInstruction:
902
+ parts.append(f"<?{node.text or ''}?>")
903
+ else:
904
+ assert isinstance(node.tag, str)
905
+ origin = _ORIGINS.get(node)
906
+ here = dict(scope)
907
+
908
+ def name_for(uri: str, local: str, kept: str | None, named: bool) -> str:
909
+ if not uri:
910
+ return local
911
+ if kept is not None and (kept or not named) and here.get(kept) == uri:
912
+ prefix: str | None = kept
913
+ else:
914
+ prefix = pick(uri, here, named)
915
+ if prefix is None: # shadowed by an inner declaration
916
+ prefix = fresh(uri, here)
917
+ here[prefix] = uri
918
+ generated.add(prefix)
919
+ decls.append((prefix, uri))
920
+ return f"{prefix}:{local}" if prefix else local
921
+
922
+ euri, elocal = _split(node.tag)
923
+ if not euri and here.get(""):
924
+ raise ValueError(f"no-namespace element {node.tag!r} inside a default namespace")
925
+ tag = name_for(euri, elocal, origin.prefix if origin else None, False)
926
+ attrs: list[str] = []
927
+ for name, value in node.attrib.items():
928
+ auri, alocal = _split(name)
929
+ kept = origin.attr_prefixes.get(name) if origin else None
930
+ attrs.append(f' {name_for(auri, alocal, kept, True)}="{_escape_attr(value)}"')
931
+ parts.append(f"<{tag}")
932
+ for prefix, uri in decls:
933
+ parts.append(f' xmlns{":" + prefix if prefix else ""}="{_escape_attr(uri)}"')
934
+ parts.extend(attrs)
935
+ if len(node) == 0 and not node.text:
936
+ parts.append("/>")
937
+ else:
938
+ parts.append(">")
939
+ if node.text:
940
+ parts.append(_escape_text(node.text))
941
+ for child in node:
942
+ child_origin = _ORIGINS.get(child)
943
+ child_decls = list(child_origin.decls) if child_origin is not None else []
944
+ write(child, {**here, **dict(child_decls)}, child_decls)
945
+ parts.append(f"</{tag}>")
946
+ if node is not elem and node.tail:
947
+ parts.append(_escape_text(node.tail))
948
+
949
+ write(elem, top_scope, top_decls)
950
+ out = "".join(parts).encode("utf-8")
951
+ problems = _mce_problems(out, outer_scope)
952
+ if problems:
953
+ raise DocControlError(
954
+ ErrorCode.INTERNAL,
955
+ "serialised paragraph names an undeclared prefix: " + "; ".join(problems),
956
+ )
957
+ return out