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,504 @@
|
|
|
1
|
+
"""Building and editing ``w:style`` elements of ``word/styles.xml``.
|
|
2
|
+
|
|
3
|
+
Styles are edited as text: the styles part is split into its ``w:style`` blocks, the
|
|
4
|
+
one block that changes is parsed, edited in order (children keep the schema
|
|
5
|
+
sequence) and put back, so every other byte of the part stays as it was. The
|
|
6
|
+
property vocabulary is the one :mod:`doc_control.model.format` reads: run keys
|
|
7
|
+
``bold italic small_caps strike underline font size_pt color highlight
|
|
8
|
+
vertical_align style``; paragraph keys ``alignment indent{left right first_line
|
|
9
|
+
hanging} spacing{before after line line_rule} keep_next keep_lines
|
|
10
|
+
page_break_before outline_level`` (lengths in twips).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import re
|
|
17
|
+
import xml.etree.ElementTree as ET
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
from xml.sax.saxutils import quoteattr, unescape
|
|
21
|
+
|
|
22
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
23
|
+
from doc_control.model.parts import W_NS, W
|
|
24
|
+
|
|
25
|
+
STYLES_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"
|
|
26
|
+
_XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
|
27
|
+
|
|
28
|
+
STYLE_ORDER = [
|
|
29
|
+
"name",
|
|
30
|
+
"aliases",
|
|
31
|
+
"basedOn",
|
|
32
|
+
"next",
|
|
33
|
+
"link",
|
|
34
|
+
"autoRedefine",
|
|
35
|
+
"hidden",
|
|
36
|
+
"uiPriority",
|
|
37
|
+
"semiHidden",
|
|
38
|
+
"unhideWhenUsed",
|
|
39
|
+
"qFormat",
|
|
40
|
+
"locked",
|
|
41
|
+
"personal",
|
|
42
|
+
"personalCompose",
|
|
43
|
+
"personalReply",
|
|
44
|
+
"rsid",
|
|
45
|
+
"pPr",
|
|
46
|
+
"rPr",
|
|
47
|
+
"tblPr",
|
|
48
|
+
"trPr",
|
|
49
|
+
"tcPr",
|
|
50
|
+
"tblStylePr",
|
|
51
|
+
]
|
|
52
|
+
PPR_ORDER = [
|
|
53
|
+
"pStyle",
|
|
54
|
+
"keepNext",
|
|
55
|
+
"keepLines",
|
|
56
|
+
"pageBreakBefore",
|
|
57
|
+
"framePr",
|
|
58
|
+
"widowControl",
|
|
59
|
+
"numPr",
|
|
60
|
+
"suppressLineNumbers",
|
|
61
|
+
"pBdr",
|
|
62
|
+
"shd",
|
|
63
|
+
"tabs",
|
|
64
|
+
"suppressAutoHyphens",
|
|
65
|
+
"kinsoku",
|
|
66
|
+
"wordWrap",
|
|
67
|
+
"overflowPunct",
|
|
68
|
+
"topLinePunct",
|
|
69
|
+
"autoSpaceDE",
|
|
70
|
+
"autoSpaceDN",
|
|
71
|
+
"bidi",
|
|
72
|
+
"adjustRightInd",
|
|
73
|
+
"snapToGrid",
|
|
74
|
+
"spacing",
|
|
75
|
+
"ind",
|
|
76
|
+
"contextualSpacing",
|
|
77
|
+
"mirrorIndents",
|
|
78
|
+
"suppressOverlap",
|
|
79
|
+
"jc",
|
|
80
|
+
"textDirection",
|
|
81
|
+
"textAlignment",
|
|
82
|
+
"textboxTightWrap",
|
|
83
|
+
"outlineLvl",
|
|
84
|
+
"divId",
|
|
85
|
+
"cnfStyle",
|
|
86
|
+
"rPr",
|
|
87
|
+
"sectPr",
|
|
88
|
+
"pPrChange",
|
|
89
|
+
]
|
|
90
|
+
RPR_ORDER = [
|
|
91
|
+
"rStyle",
|
|
92
|
+
"rFonts",
|
|
93
|
+
"b",
|
|
94
|
+
"bCs",
|
|
95
|
+
"i",
|
|
96
|
+
"iCs",
|
|
97
|
+
"caps",
|
|
98
|
+
"smallCaps",
|
|
99
|
+
"strike",
|
|
100
|
+
"dstrike",
|
|
101
|
+
"outline",
|
|
102
|
+
"shadow",
|
|
103
|
+
"emboss",
|
|
104
|
+
"imprint",
|
|
105
|
+
"noProof",
|
|
106
|
+
"snapToGrid",
|
|
107
|
+
"vanish",
|
|
108
|
+
"webHidden",
|
|
109
|
+
"color",
|
|
110
|
+
"spacing",
|
|
111
|
+
"w",
|
|
112
|
+
"kern",
|
|
113
|
+
"position",
|
|
114
|
+
"sz",
|
|
115
|
+
"szCs",
|
|
116
|
+
"highlight",
|
|
117
|
+
"u",
|
|
118
|
+
"effect",
|
|
119
|
+
"bdr",
|
|
120
|
+
"shd",
|
|
121
|
+
"fitText",
|
|
122
|
+
"vertAlign",
|
|
123
|
+
"rtl",
|
|
124
|
+
"cs",
|
|
125
|
+
"em",
|
|
126
|
+
"lang",
|
|
127
|
+
"eastAsianLayout",
|
|
128
|
+
"specVanish",
|
|
129
|
+
"oMath",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
RUN_KEYS = [
|
|
133
|
+
"bold",
|
|
134
|
+
"italic",
|
|
135
|
+
"small_caps",
|
|
136
|
+
"strike",
|
|
137
|
+
"underline",
|
|
138
|
+
"font",
|
|
139
|
+
"size_pt",
|
|
140
|
+
"color",
|
|
141
|
+
"highlight",
|
|
142
|
+
"vertical_align",
|
|
143
|
+
"style",
|
|
144
|
+
]
|
|
145
|
+
PARA_KEYS = [
|
|
146
|
+
"alignment",
|
|
147
|
+
"indent",
|
|
148
|
+
"spacing",
|
|
149
|
+
"keep_next",
|
|
150
|
+
"keep_lines",
|
|
151
|
+
"page_break_before",
|
|
152
|
+
"outline_level",
|
|
153
|
+
]
|
|
154
|
+
_RUN_TOGGLES = {"bold": "b", "italic": "i", "small_caps": "smallCaps", "strike": "strike"}
|
|
155
|
+
_PARA_TOGGLES = {
|
|
156
|
+
"keep_next": "keepNext",
|
|
157
|
+
"keep_lines": "keepLines",
|
|
158
|
+
"page_break_before": "pageBreakBefore",
|
|
159
|
+
}
|
|
160
|
+
_VAL_RUN = {
|
|
161
|
+
"underline": "u",
|
|
162
|
+
"color": "color",
|
|
163
|
+
"highlight": "highlight",
|
|
164
|
+
"vertical_align": "vertAlign",
|
|
165
|
+
"style": "rStyle",
|
|
166
|
+
}
|
|
167
|
+
_INDENT = {"left": "left", "right": "right", "first_line": "firstLine", "hanging": "hanging"}
|
|
168
|
+
_SPACING = {"before": "before", "after": "after", "line": "line", "line_rule": "lineRule"}
|
|
169
|
+
# Local names of the children the vocabulary above owns (cleared on a full replace).
|
|
170
|
+
_RUN_OWNED = {*_RUN_TOGGLES.values(), *_VAL_RUN.values(), "rFonts", "sz", "szCs"}
|
|
171
|
+
_PARA_OWNED = {*_PARA_TOGGLES.values(), "jc", "ind", "spacing", "outlineLvl"}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def invalid(message: str, **details: Any) -> DocControlError:
|
|
175
|
+
return DocControlError(ErrorCode.SCHEMA_INVALID, message, **details)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _local(elem: ET.Element) -> str:
|
|
179
|
+
return elem.tag.rpartition("}")[2]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _put(parent: ET.Element, local: str, order: list[str], attrs: dict[str, str] | None) -> None:
|
|
183
|
+
"""Replace the ``local`` child of ``parent`` (``attrs`` None: just remove it)."""
|
|
184
|
+
for old in [c for c in parent if _local(c) == local]:
|
|
185
|
+
parent.remove(old)
|
|
186
|
+
if attrs is None:
|
|
187
|
+
return
|
|
188
|
+
new = ET.Element(W + local, {W + k: v for k, v in attrs.items()})
|
|
189
|
+
rank = order.index(local)
|
|
190
|
+
at = len(parent)
|
|
191
|
+
for i, child in enumerate(parent):
|
|
192
|
+
name = _local(child)
|
|
193
|
+
if name in order and order.index(name) > rank:
|
|
194
|
+
at = i
|
|
195
|
+
break
|
|
196
|
+
parent.insert(at, new)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_bool(key: str, value: Any) -> bool:
|
|
200
|
+
if not isinstance(value, bool):
|
|
201
|
+
raise invalid(f"{key} must be true or false")
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _toggle(key: str, value: Any) -> dict[str, str] | None:
|
|
206
|
+
if value is None:
|
|
207
|
+
return None
|
|
208
|
+
return {} if _check_bool(key, value) else {"val": "0"}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _check_str(key: str, value: Any) -> str:
|
|
212
|
+
if not isinstance(value, str) or not value:
|
|
213
|
+
raise invalid(f"{key} must be a non-empty string")
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _check_int(key: str, value: Any) -> int:
|
|
218
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
219
|
+
raise invalid(f"{key} must be an integer (twips)")
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _sub(patch: dict[str, Any], key: str, allowed: dict[str, str]) -> dict[str, Any]:
|
|
224
|
+
value = patch[key]
|
|
225
|
+
if not isinstance(value, dict):
|
|
226
|
+
raise invalid(f"{key} must be an object")
|
|
227
|
+
extra = sorted(set(value) - set(allowed))
|
|
228
|
+
if extra:
|
|
229
|
+
raise invalid(f"unknown {key} key {extra[0]!r}", allowed=sorted(allowed))
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def apply_run(rpr: ET.Element, patch: dict[str, Any]) -> None:
|
|
234
|
+
"""Set (or, for a ``None`` value, remove) the run properties in ``patch``."""
|
|
235
|
+
unknown = sorted(set(patch) - set(RUN_KEYS))
|
|
236
|
+
if unknown:
|
|
237
|
+
raise invalid(f"unknown run key {unknown[0]!r}", allowed=RUN_KEYS)
|
|
238
|
+
order = RPR_ORDER
|
|
239
|
+
for key, value in patch.items():
|
|
240
|
+
if key in _RUN_TOGGLES:
|
|
241
|
+
local = _RUN_TOGGLES[key]
|
|
242
|
+
_put(rpr, local, order, _toggle(key, value))
|
|
243
|
+
elif key in _VAL_RUN:
|
|
244
|
+
local = _VAL_RUN[key]
|
|
245
|
+
_put(rpr, local, order, None if value is None else {"val": _check_str(key, value)})
|
|
246
|
+
elif key == "font":
|
|
247
|
+
_put(rpr, "rFonts", order, None)
|
|
248
|
+
if value is not None:
|
|
249
|
+
name = _check_str(key, value)
|
|
250
|
+
_put(rpr, "rFonts", order, {"ascii": name, "hAnsi": name})
|
|
251
|
+
elif key == "size_pt":
|
|
252
|
+
for local in ("sz", "szCs"):
|
|
253
|
+
if value is None:
|
|
254
|
+
_put(rpr, local, order, None)
|
|
255
|
+
continue
|
|
256
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
257
|
+
raise invalid("size_pt must be a positive number")
|
|
258
|
+
half = value * 2
|
|
259
|
+
if half != int(half):
|
|
260
|
+
raise invalid("size_pt must be a multiple of 0.5")
|
|
261
|
+
_put(rpr, local, order, {"val": str(int(half))})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def apply_para(ppr: ET.Element, patch: dict[str, Any]) -> None:
|
|
265
|
+
"""Set (or remove, for ``None``) the paragraph properties in ``patch``."""
|
|
266
|
+
unknown = sorted(set(patch) - set(PARA_KEYS))
|
|
267
|
+
if unknown:
|
|
268
|
+
raise invalid(f"unknown paragraph key {unknown[0]!r}", allowed=PARA_KEYS)
|
|
269
|
+
order = PPR_ORDER
|
|
270
|
+
for key, value in patch.items():
|
|
271
|
+
if key in _PARA_TOGGLES:
|
|
272
|
+
local = _PARA_TOGGLES[key]
|
|
273
|
+
_put(ppr, local, order, _toggle(key, value))
|
|
274
|
+
elif key == "alignment":
|
|
275
|
+
_put(ppr, "jc", order, None if value is None else {"val": _check_str(key, value)})
|
|
276
|
+
elif key == "outline_level":
|
|
277
|
+
if value is not None and (_check_int(key, value) < 0 or value > 9):
|
|
278
|
+
raise invalid("outline_level must be 0-9")
|
|
279
|
+
_put(ppr, "outlineLvl", order, None if value is None else {"val": str(value)})
|
|
280
|
+
else: # indent, spacing
|
|
281
|
+
names = _INDENT if key == "indent" else _SPACING
|
|
282
|
+
local = "ind" if key == "indent" else "spacing"
|
|
283
|
+
if value is None:
|
|
284
|
+
_put(ppr, local, order, None)
|
|
285
|
+
continue
|
|
286
|
+
attrs: dict[str, str] = {}
|
|
287
|
+
for sub, raw in _sub(patch, key, names).items():
|
|
288
|
+
if raw is None:
|
|
289
|
+
continue
|
|
290
|
+
attrs[names[sub]] = (
|
|
291
|
+
_check_str(sub, raw) if sub == "line_rule" else str(_check_int(sub, raw))
|
|
292
|
+
)
|
|
293
|
+
_put(ppr, local, order, attrs or None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _owned_clear(elem: ET.Element, owned: set[str]) -> None:
|
|
297
|
+
for child in [c for c in elem if _local(c) in owned]:
|
|
298
|
+
elem.remove(child)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def apply_properties(
|
|
302
|
+
style: ET.Element, run: dict[str, Any] | None, para: dict[str, Any] | None, *, replace: bool
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Edit the ``w:rPr`` / ``w:pPr`` of ``style``; ``replace`` drops the old values."""
|
|
305
|
+
for local, patch, owned, apply in (
|
|
306
|
+
("pPr", para, _PARA_OWNED, apply_para),
|
|
307
|
+
("rPr", run, _RUN_OWNED, apply_run),
|
|
308
|
+
):
|
|
309
|
+
if patch is None:
|
|
310
|
+
continue
|
|
311
|
+
if not isinstance(patch, dict):
|
|
312
|
+
raise invalid(f"{'paragraph' if local == 'pPr' else 'run'} must be an object")
|
|
313
|
+
props = style.find(W + local)
|
|
314
|
+
if props is None:
|
|
315
|
+
props = ET.Element(W + local)
|
|
316
|
+
_put_elem(style, props)
|
|
317
|
+
if replace:
|
|
318
|
+
_owned_clear(props, owned)
|
|
319
|
+
apply(props, patch)
|
|
320
|
+
if len(props) == 0 and not props.attrib:
|
|
321
|
+
style.remove(props)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _put_elem(style: ET.Element, new: ET.Element) -> None:
|
|
325
|
+
rank = STYLE_ORDER.index(_local(new))
|
|
326
|
+
at = len(style)
|
|
327
|
+
for i, child in enumerate(style):
|
|
328
|
+
name = _local(child)
|
|
329
|
+
if name in STYLE_ORDER and STYLE_ORDER.index(name) > rank:
|
|
330
|
+
at = i
|
|
331
|
+
break
|
|
332
|
+
style.insert(at, new)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def set_child(style: ET.Element, local: str, val: str | None, *, flag: bool = False) -> None:
|
|
336
|
+
"""Set a ``w:name`` / ``basedOn`` / ``next`` / ``link`` / ``qFormat`` child (None removes)."""
|
|
337
|
+
for old in [c for c in style if _local(c) == local]:
|
|
338
|
+
style.remove(old)
|
|
339
|
+
if val is None:
|
|
340
|
+
return
|
|
341
|
+
_put_elem(style, ET.Element(W + local, {} if flag else {W + "val": val}))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def new_style(
|
|
345
|
+
style_id: str, name: str, kind: str, *, default: bool = False, custom: bool = False
|
|
346
|
+
) -> ET.Element:
|
|
347
|
+
attrs = {W + "type": kind}
|
|
348
|
+
if default:
|
|
349
|
+
attrs[W + "default"] = "1"
|
|
350
|
+
if custom:
|
|
351
|
+
attrs[W + "customStyle"] = "1"
|
|
352
|
+
attrs[W + "styleId"] = style_id
|
|
353
|
+
style = ET.Element(W + "style", attrs)
|
|
354
|
+
set_child(style, "name", name)
|
|
355
|
+
return style
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass(frozen=True)
|
|
359
|
+
class Rec:
|
|
360
|
+
"""The identity fields of one style, as the editor sees them."""
|
|
361
|
+
|
|
362
|
+
id: str
|
|
363
|
+
name: str
|
|
364
|
+
type: str
|
|
365
|
+
based_on: str | None
|
|
366
|
+
next: str | None
|
|
367
|
+
link: str | None
|
|
368
|
+
default: bool
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class StylesText:
|
|
372
|
+
"""The text of a ``styles.xml`` with block-level edits."""
|
|
373
|
+
|
|
374
|
+
def __init__(self, data: bytes | None) -> None:
|
|
375
|
+
self.created = data is None
|
|
376
|
+
if data is None:
|
|
377
|
+
self.text = f'{_XML_DECL}<w:styles xmlns:w="{W_NS}"></w:styles>'
|
|
378
|
+
else:
|
|
379
|
+
try:
|
|
380
|
+
self.text = data.decode("utf-8")
|
|
381
|
+
except UnicodeDecodeError as exc:
|
|
382
|
+
raise DocControlError(
|
|
383
|
+
ErrorCode.UNSUPPORTED_CONTENT, "styles.xml is not UTF-8 encoded"
|
|
384
|
+
) from exc
|
|
385
|
+
if self.text.startswith(""):
|
|
386
|
+
self.text = self.text[1:]
|
|
387
|
+
m = re.search(r"<([\w.-]+):styles\b([^>]*?)(/?)>", self.text)
|
|
388
|
+
if m is None:
|
|
389
|
+
raise DocControlError(
|
|
390
|
+
ErrorCode.UNSUPPORTED_CONTENT, "styles.xml has no prefixed w:styles root"
|
|
391
|
+
)
|
|
392
|
+
self.prefix = m.group(1)
|
|
393
|
+
self.decls = dict(re.findall(r'xmlns:([\w.-]+)="([^"]*)"', m.group(2)))
|
|
394
|
+
if self.decls.get(self.prefix) != W_NS:
|
|
395
|
+
raise DocControlError(
|
|
396
|
+
ErrorCode.UNSUPPORTED_CONTENT,
|
|
397
|
+
"styles.xml root is not in the WordprocessingML namespace",
|
|
398
|
+
)
|
|
399
|
+
if m.group(3): # <w:styles .../>
|
|
400
|
+
open_tag = f"<{self.prefix}:styles{m.group(2)}>"
|
|
401
|
+
self.text = (
|
|
402
|
+
self.text[: m.start()]
|
|
403
|
+
+ open_tag
|
|
404
|
+
+ f"</{self.prefix}:styles>"
|
|
405
|
+
+ self.text[m.end() :]
|
|
406
|
+
)
|
|
407
|
+
self.open_tag = (
|
|
408
|
+
self.text[self.text.index(f"<{self.prefix}:styles") :].split(">", 1)[0] + ">"
|
|
409
|
+
)
|
|
410
|
+
for prefix, uri in self.decls.items():
|
|
411
|
+
with contextlib.suppress(ValueError):
|
|
412
|
+
ET.register_namespace(prefix, uri)
|
|
413
|
+
p = re.escape(self.prefix)
|
|
414
|
+
self._block = re.compile(rf"<{p}:style\b([^>]*?)(?:/>|>(.*?)</{p}:style>)", re.DOTALL)
|
|
415
|
+
self._id = re.compile(rf'\b{p}:styleId="([^"]*)"')
|
|
416
|
+
|
|
417
|
+
# -- reading ---------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def _spans(self) -> list[tuple[int, int, str]]:
|
|
420
|
+
out = []
|
|
421
|
+
for m in self._block.finditer(self.text):
|
|
422
|
+
sid = self._id.search(m.group(1))
|
|
423
|
+
if sid:
|
|
424
|
+
out.append((m.start(), m.end(), unescape(sid.group(1), {""": '"'})))
|
|
425
|
+
return out
|
|
426
|
+
|
|
427
|
+
def records(self) -> dict[str, Rec]:
|
|
428
|
+
root = ET.fromstring(self.text.encode("utf-8"))
|
|
429
|
+
out: dict[str, Rec] = {}
|
|
430
|
+
for e in root.findall(W + "style"):
|
|
431
|
+
sid = e.get(W + "styleId")
|
|
432
|
+
if not sid:
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
def val(tag: str, e: ET.Element = e) -> str | None:
|
|
436
|
+
c = e.find(W + tag)
|
|
437
|
+
return c.get(W + "val") if c is not None else None
|
|
438
|
+
|
|
439
|
+
out[sid] = Rec(
|
|
440
|
+
sid,
|
|
441
|
+
val("name") or sid,
|
|
442
|
+
e.get(W + "type") or "paragraph",
|
|
443
|
+
val("basedOn"),
|
|
444
|
+
val("next"),
|
|
445
|
+
val("link"),
|
|
446
|
+
(e.get(W + "default") or "0").lower() in ("1", "true", "on"),
|
|
447
|
+
)
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
def element(self, style_id: str) -> ET.Element:
|
|
451
|
+
for start, end, sid in self._spans():
|
|
452
|
+
if sid == style_id:
|
|
453
|
+
root = ET.fromstring(
|
|
454
|
+
(self.open_tag + self.text[start:end] + f"</{self.prefix}:styles>").encode(
|
|
455
|
+
"utf-8"
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
return root[0]
|
|
459
|
+
raise DocControlError(ErrorCode.STYLE_NOT_FOUND, f"no style {style_id!r}", style=style_id)
|
|
460
|
+
|
|
461
|
+
# -- writing ---------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
def serialize(self, elem: ET.Element) -> str:
|
|
464
|
+
out = ET.tostring(elem, encoding="unicode")
|
|
465
|
+
head, sep, rest = out.partition(">")
|
|
466
|
+
head = re.sub(
|
|
467
|
+
r' xmlns:([\w.-]+)="([^"]*)"',
|
|
468
|
+
lambda m: "" if self.decls.get(m.group(1)) == m.group(2) else m.group(0),
|
|
469
|
+
head,
|
|
470
|
+
)
|
|
471
|
+
return head + sep + rest
|
|
472
|
+
|
|
473
|
+
def put(self, elem: ET.Element, replacing: str | None = None) -> None:
|
|
474
|
+
"""Replace the block of ``replacing`` (default: the element's own id), or append it."""
|
|
475
|
+
sid = replacing or elem.get(W + "styleId") or ""
|
|
476
|
+
block = self.serialize(elem)
|
|
477
|
+
for start, end, found in self._spans():
|
|
478
|
+
if found == sid:
|
|
479
|
+
self.text = self.text[:start] + block + self.text[end:]
|
|
480
|
+
return
|
|
481
|
+
close = f"</{self.prefix}:styles>"
|
|
482
|
+
at = self.text.rindex(close)
|
|
483
|
+
self.text = self.text[:at] + block + self.text[at:]
|
|
484
|
+
|
|
485
|
+
def remove(self, style_id: str) -> None:
|
|
486
|
+
for start, end, sid in self._spans():
|
|
487
|
+
if sid == style_id:
|
|
488
|
+
self.text = self.text[:start] + self.text[end:]
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
def retarget(self, old: str, new: str | None, tags: str = "basedOn|next|link") -> None:
|
|
492
|
+
"""Point the ``tags`` references at ``new`` where they name ``old`` (None: drop them)."""
|
|
493
|
+
p = re.escape(self.prefix)
|
|
494
|
+
pat = re.compile(rf"<{p}:({tags})\b[^>]*?\b{p}:val={quoteattr(old)}[^>]*?/>")
|
|
495
|
+
if new is None:
|
|
496
|
+
self.text = pat.sub("", self.text)
|
|
497
|
+
else:
|
|
498
|
+
self.text = pat.sub(
|
|
499
|
+
lambda m: f"<{self.prefix}:{m.group(1)} {self.prefix}:val={quoteattr(new)}/>",
|
|
500
|
+
self.text,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def data(self) -> bytes:
|
|
504
|
+
return self.text.encode("utf-8")
|