doc-control 0.0.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doc_control/__init__.py +4 -0
- doc_control/__main__.py +3 -0
- doc_control/cli.py +80 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/model/__init__.py +1 -0
- doc_control/model/address.py +113 -0
- doc_control/model/align.py +529 -0
- doc_control/model/anchor.py +609 -0
- doc_control/model/annotations.py +280 -0
- doc_control/model/document.py +109 -0
- doc_control/model/format.py +109 -0
- doc_control/model/index.py +353 -0
- doc_control/model/layout.py +143 -0
- doc_control/model/normalized.py +160 -0
- doc_control/model/pages.py +403 -0
- doc_control/model/parts.py +50 -0
- doc_control/model/search.py +403 -0
- doc_control/model/segment.py +445 -0
- doc_control/model/structure.py +418 -0
- doc_control/model/styles.py +134 -0
- doc_control/ooxml/__init__.py +1 -0
- doc_control/ooxml/commentparts.py +679 -0
- doc_control/ooxml/formatting.py +181 -0
- doc_control/ooxml/resolve.py +269 -0
- doc_control/ooxml/revisions.py +1024 -0
- doc_control/ooxml/splice.py +957 -0
- doc_control/ooxml/styledef.py +504 -0
- doc_control/ooxml/zipio.py +454 -0
- doc_control/ops/__init__.py +1 -0
- doc_control/ops/anchor.py +135 -0
- doc_control/ops/batch.py +177 -0
- doc_control/ops/comments.py +433 -0
- doc_control/ops/compare.py +620 -0
- doc_control/ops/diff.py +177 -0
- doc_control/ops/dotx.py +206 -0
- doc_control/ops/find.py +190 -0
- doc_control/ops/history.py +479 -0
- doc_control/ops/layout.py +40 -0
- doc_control/ops/locate.py +364 -0
- doc_control/ops/meta.py +49 -0
- doc_control/ops/page.py +104 -0
- doc_control/ops/paging.py +124 -0
- doc_control/ops/read.py +517 -0
- doc_control/ops/recipe.py +108 -0
- doc_control/ops/rename.py +370 -0
- doc_control/ops/revision.py +616 -0
- doc_control/ops/segment.py +289 -0
- doc_control/ops/styleset.py +262 -0
- doc_control/ops/stylewrite.py +573 -0
- doc_control/ops/text.py +207 -0
- doc_control/ops/tracked.py +1441 -0
- doc_control/py.typed +0 -0
- doc_control/recipes/engine.py +209 -0
- doc_control/recipes/schema.py +140 -0
- doc_control/recipes/store.py +96 -0
- doc_control/registry.py +76 -0
- doc_control/safety/__init__.py +38 -0
- doc_control/safety/history.py +508 -0
- doc_control/safety/lock.py +69 -0
- doc_control/safety/store.py +415 -0
- doc_control/safety/verify.py +357 -0
- doc_control/schema.py +143 -0
- doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
- doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
- doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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("&", "&").replace("<", "<").replace(">", ">").replace("\r", " ")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _escape_attr(s: str) -> str:
|
|
793
|
+
return (
|
|
794
|
+
s.replace("&", "&")
|
|
795
|
+
.replace("<", "<")
|
|
796
|
+
.replace(">", ">")
|
|
797
|
+
.replace('"', """)
|
|
798
|
+
.replace("\t", "	")
|
|
799
|
+
.replace("\n", " ")
|
|
800
|
+
.replace("\r", " ")
|
|
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
|