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,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), {"&quot;": '"'})))
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")