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,181 @@
1
+ """Direct paragraph and run properties: set them on a ``w:pPr`` / ``w:rPr`` in schema order.
2
+
3
+ :func:`apply_ppr` and :func:`apply_rpr` take a property dict (the keys of
4
+ :data:`PARAGRAPH_KEYS` and :data:`RUN_KEYS`) and change the element in place. They return
5
+ whether anything changed; setting what is already there changes nothing. Children are
6
+ inserted where CT_PPrBase / CT_RPr put them, so Word accepts the result. Lengths come in
7
+ centimetres and points and are written in twips and half-points.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import xml.etree.ElementTree as ET
13
+ from typing import Any
14
+
15
+ from doc_control.ooxml.splice import W_NS
16
+
17
+ W = f"{{{W_NS}}}"
18
+
19
+ PARAGRAPH_KEYS = (
20
+ "align",
21
+ "indent_left_cm",
22
+ "indent_right_cm",
23
+ "indent_first_line_cm",
24
+ "spacing_before_pt",
25
+ "spacing_after_pt",
26
+ "line_spacing",
27
+ )
28
+ RUN_KEYS = ("bold", "italic", "underline", "small_caps", "font", "size_pt")
29
+ ALIGNMENTS = {"left": "left", "center": "center", "right": "right", "justify": "both"}
30
+
31
+ _PPR_NAMES = """
32
+ pStyle keepNext keepLines pageBreakBefore framePr widowControl numPr suppressLineNumbers pBdr
33
+ shd tabs suppressAutoHyphens kinsoku wordWrap overflowPunct topLinePunct autoSpaceDE
34
+ autoSpaceDN bidi adjustRightInd snapToGrid spacing ind contextualSpacing mirrorIndents
35
+ suppressOverlap jc textDirection textAlignment textboxTightWrap outlineLvl divId cnfStyle
36
+ rPr sectPr pPrChange
37
+ """
38
+ _RPR_NAMES = """
39
+ ins del moveFrom moveTo rStyle rFonts b bCs i iCs caps smallCaps strike dstrike outline
40
+ shadow emboss imprint noProof snapToGrid vanish webHidden color spacing w kern position sz
41
+ szCs highlight u effect bdr shd fitText vertAlign rtl cs em lang eastAsianLayout specVanish
42
+ oMath rPrChange
43
+ """
44
+ _PPR_ORDER = tuple(W + n for n in _PPR_NAMES.split())
45
+ _RPR_ORDER = tuple(W + n for n in _RPR_NAMES.split())
46
+ _THEME_FONTS = tuple(
47
+ W + n for n in ("asciiTheme", "hAnsiTheme", "cstheme", "eastAsiaTheme", "hint")
48
+ )
49
+ _OFF = frozenset({"0", "false", "off"})
50
+ _TWIPS_PER_CM = 1440 / 2.54
51
+
52
+
53
+ def twips(cm: float) -> int:
54
+ return round(cm * _TWIPS_PER_CM)
55
+
56
+
57
+ def _child(parent: ET.Element, tag: str, order: tuple[str, ...]) -> tuple[ET.Element, bool]:
58
+ """The child ``tag`` of ``parent``, created in schema position if missing."""
59
+ found = parent.find(tag)
60
+ if found is not None:
61
+ return found, False
62
+ rank = order.index(tag)
63
+ at = len(parent)
64
+ for i, existing in enumerate(parent):
65
+ if (
66
+ isinstance(existing.tag, str)
67
+ and existing.tag in order
68
+ and order.index(existing.tag) > rank
69
+ ):
70
+ at = i
71
+ break
72
+ elem = ET.Element(tag)
73
+ parent.insert(at, elem)
74
+ return elem, True
75
+
76
+
77
+ def _set_attrs(elem: ET.Element, attrs: dict[str, str], drop: tuple[str, ...] = ()) -> bool:
78
+ changed = False
79
+ for name in drop:
80
+ if name in elem.attrib:
81
+ del elem.attrib[name]
82
+ changed = True
83
+ for name, value in attrs.items():
84
+ if elem.get(name) != value:
85
+ elem.set(name, value)
86
+ changed = True
87
+ return changed
88
+
89
+
90
+ def _set_toggle(rpr: ET.Element, name: str, on: bool) -> bool:
91
+ tag = W + name
92
+ existing = rpr.find(tag)
93
+ if existing is not None:
94
+ current = (existing.get(W + "val") or "1").lower() not in _OFF
95
+ if current == on:
96
+ return False
97
+ elem, _ = _child(rpr, tag, _RPR_ORDER)
98
+ if on:
99
+ elem.attrib.pop(W + "val", None)
100
+ else:
101
+ elem.set(W + "val", "0")
102
+ return True
103
+
104
+
105
+ def _set_val(parent: ET.Element, name: str, value: str, order: tuple[str, ...]) -> bool:
106
+ elem, created = _child(parent, W + name, order)
107
+ return _set_attrs(elem, {W + "val": value}) or created
108
+
109
+
110
+ def apply_rpr(rpr: ET.Element, props: dict[str, Any]) -> bool:
111
+ """Set run properties (``RUN_KEYS`` plus ``style``, a character style id)."""
112
+ changed = False
113
+ if "style" in props:
114
+ changed |= _set_val(rpr, "rStyle", props["style"], _RPR_ORDER)
115
+ if "bold" in props:
116
+ changed |= _set_toggle(rpr, "b", props["bold"])
117
+ if "italic" in props:
118
+ changed |= _set_toggle(rpr, "i", props["italic"])
119
+ if "small_caps" in props:
120
+ changed |= _set_toggle(rpr, "smallCaps", props["small_caps"])
121
+ if "underline" in props:
122
+ changed |= _set_val(rpr, "u", "single" if props["underline"] else "none", _RPR_ORDER)
123
+ if "font" in props:
124
+ fonts, created = _child(rpr, W + "rFonts", _RPR_ORDER)
125
+ name = props["font"]
126
+ changed |= (
127
+ _set_attrs(
128
+ fonts,
129
+ {W + k: name for k in ("ascii", "hAnsi", "cs", "eastAsia")},
130
+ drop=_THEME_FONTS,
131
+ )
132
+ or created
133
+ )
134
+ if "size_pt" in props:
135
+ half = str(round(props["size_pt"] * 2))
136
+ changed |= _set_val(rpr, "sz", half, _RPR_ORDER)
137
+ changed |= _set_val(rpr, "szCs", half, _RPR_ORDER)
138
+ return changed
139
+
140
+
141
+ def apply_ppr(ppr: ET.Element, props: dict[str, Any]) -> bool:
142
+ """Set paragraph properties (``PARAGRAPH_KEYS`` plus ``style``, a paragraph style id)."""
143
+ changed = False
144
+ if "style" in props:
145
+ changed |= _set_val(ppr, "pStyle", props["style"], _PPR_ORDER)
146
+ if "align" in props:
147
+ changed |= _set_val(ppr, "jc", ALIGNMENTS[props["align"]], _PPR_ORDER)
148
+ ind: dict[str, str] = {}
149
+ ind_drop: tuple[str, ...] = ()
150
+ if "indent_left_cm" in props:
151
+ ind[W + "left"] = str(twips(props["indent_left_cm"]))
152
+ ind_drop += (W + "start", W + "leftChars")
153
+ if "indent_right_cm" in props:
154
+ ind[W + "right"] = str(twips(props["indent_right_cm"]))
155
+ ind_drop += (W + "end", W + "rightChars")
156
+ if "indent_first_line_cm" in props:
157
+ value = twips(props["indent_first_line_cm"])
158
+ if value >= 0:
159
+ ind[W + "firstLine"] = str(value)
160
+ ind_drop += (W + "hanging", W + "hangingChars", W + "firstLineChars")
161
+ else:
162
+ ind[W + "hanging"] = str(-value)
163
+ ind_drop += (W + "firstLine", W + "firstLineChars", W + "hangingChars")
164
+ if ind:
165
+ elem, created = _child(ppr, W + "ind", _PPR_ORDER)
166
+ changed |= _set_attrs(elem, ind, drop=ind_drop) or created
167
+ spacing: dict[str, str] = {}
168
+ spacing_drop: tuple[str, ...] = ()
169
+ if "spacing_before_pt" in props:
170
+ spacing[W + "before"] = str(round(props["spacing_before_pt"] * 20))
171
+ spacing_drop += (W + "beforeLines", W + "beforeAutospacing")
172
+ if "spacing_after_pt" in props:
173
+ spacing[W + "after"] = str(round(props["spacing_after_pt"] * 20))
174
+ spacing_drop += (W + "afterLines", W + "afterAutospacing")
175
+ if "line_spacing" in props:
176
+ spacing[W + "line"] = str(round(props["line_spacing"] * 240))
177
+ spacing[W + "lineRule"] = "auto"
178
+ if spacing:
179
+ elem, created = _child(ppr, W + "spacing", _PPR_ORDER)
180
+ changed |= _set_attrs(elem, spacing, drop=spacing_drop) or created
181
+ return changed
@@ -0,0 +1,269 @@
1
+ """Accepting and rejecting tracked changes in one paragraph element.
2
+
3
+ :func:`scan` lists the revisions of a paragraph (as ``revisions.list`` reports
4
+ them) and :func:`resolve_paragraph` applies a decision, ``accept`` or ``reject``,
5
+ to chosen ones. Both work on the element from
6
+ :meth:`doc_control.ooxml.splice.DocumentSpans.parse_paragraph`; the element is
7
+ edited in place and the caller serialises and splices it.
8
+
9
+ What each revision does
10
+ ``w:ins``, ``w:moveTo`` (content): accepting unwraps the element and keeps its
11
+ content, rejecting removes it with everything inside.
12
+ ``w:del``, ``w:moveFrom``: accepting removes the element with its content,
13
+ rejecting unwraps it and turns ``w:delText``/``w:delInstrText`` back into
14
+ ``w:t``/``w:instrText``.
15
+ ``w:rPrChange``: accepting drops the record, rejecting puts the recorded
16
+ run properties back. ``w:pPrChange``: the same for paragraph properties
17
+ (the paragraph's mark properties and section break stay).
18
+ Paragraph marks (``w:pPr/w:rPr`` holding ``w:ins``, ``w:del``, ``w:moveFrom``
19
+ or ``w:moveTo``): the marker is dropped; when the decision removes the mark
20
+ (accepting a deleted or moved-away mark, rejecting an inserted or moved-in
21
+ one) :func:`resolve_paragraph` reports it, and the caller joins the paragraph
22
+ with the next one.
23
+
24
+ Nesting
25
+ Decisions apply top down: rejecting an insertion removes a deletion nested in
26
+ it, whatever its own decision; accepting it unwraps it and leaves the nested
27
+ deletion pending (or resolved by its own decision).
28
+
29
+ What survives a removal
30
+ Bookmark, comment-range and permission markers inside removed content are kept
31
+ where they were, and so is a run holding a comment reference, so removing text
32
+ never orphans a comment or splits a bookmark.
33
+
34
+ Not covered: revisions in text boxes (``w:txbxContent``) are left as they are, as
35
+ ``revisions.list`` does not report them, and table row and cell revisions live
36
+ outside paragraphs.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import copy
42
+ import xml.etree.ElementTree as ET
43
+ from dataclasses import dataclass
44
+
45
+ from doc_control.ooxml.revisions import AUTHOR, DEL, DELTEXT, ID, INS, PPR, RPR, R, T, W
46
+
47
+ MOVE_FROM, MOVE_TO = W + "moveFrom", W + "moveTo"
48
+ RPR_CHANGE, PPR_CHANGE = W + "rPrChange", W + "pPrChange"
49
+ SECT_PR = W + "sectPr"
50
+ _TEXT_BOX = W + "txbxContent"
51
+ _DEL_INSTR, _INSTR = W + "delInstrText", W + "instrText"
52
+ _COMMENT_REFERENCE = W + "commentReference"
53
+ _KEPT_MARKERS = frozenset(
54
+ W + name
55
+ for name in (
56
+ "bookmarkStart",
57
+ "bookmarkEnd",
58
+ "commentRangeStart",
59
+ "commentRangeEnd",
60
+ "permStart",
61
+ "permEnd",
62
+ )
63
+ )
64
+
65
+ _CONTENT_KIND = {INS: "insert", DEL: "delete", MOVE_FROM: "move_from", MOVE_TO: "move_to"}
66
+ _MARK_KIND = {
67
+ INS: "mark_insert",
68
+ DEL: "mark_delete",
69
+ MOVE_FROM: "mark_move_from",
70
+ MOVE_TO: "mark_move_to",
71
+ }
72
+ _MARK_REMOVED = frozenset(
73
+ {
74
+ ("mark_insert", "reject"),
75
+ ("mark_move_to", "reject"),
76
+ ("mark_delete", "accept"),
77
+ ("mark_move_from", "accept"),
78
+ }
79
+ )
80
+ _KEEPS_CONTENT_WHEN_ACCEPTED = frozenset({INS, MOVE_TO})
81
+
82
+ ACCEPT, REJECT = "accept", "reject"
83
+
84
+
85
+ @dataclass(eq=False)
86
+ class Revision:
87
+ """One revision found in a paragraph; ``kind`` is the ``revisions.list`` type."""
88
+
89
+ id: str
90
+ kind: str
91
+ author: str
92
+ elem: ET.Element
93
+
94
+
95
+ def scan(p: ET.Element) -> list[Revision]:
96
+ """The revisions of paragraph ``p`` in document order (text boxes excluded)."""
97
+ out: list[Revision] = []
98
+ _scan(p, out)
99
+ return out
100
+
101
+
102
+ def _scan(elem: ET.Element, out: list[Revision]) -> None:
103
+ for child in elem:
104
+ tag = child.tag
105
+ if not isinstance(tag, str) or tag == _TEXT_BOX:
106
+ continue
107
+ if tag == PPR:
108
+ for mark in child.iterfind(f"{RPR}/*"):
109
+ if mark.tag in _MARK_KIND:
110
+ out.append(_revision(mark, _MARK_KIND[mark.tag]))
111
+ elif mark.tag == RPR_CHANGE:
112
+ out.append(_revision(mark, "mark_format"))
113
+ change = child.find(PPR_CHANGE)
114
+ if change is not None:
115
+ out.append(_revision(change, "paragraph_format"))
116
+ continue
117
+ if tag in _CONTENT_KIND:
118
+ out.append(_revision(child, _CONTENT_KIND[tag]))
119
+ elif tag == RPR_CHANGE:
120
+ out.append(_revision(child, "format"))
121
+ _scan(child, out)
122
+
123
+
124
+ def _revision(elem: ET.Element, kind: str) -> Revision:
125
+ return Revision(elem.get(ID) or "", kind, elem.get(AUTHOR) or "", elem)
126
+
127
+
128
+ def resolve_paragraph(p: ET.Element, actions: dict[int, str]) -> bool:
129
+ """Apply ``actions`` (``id(revision.elem)`` -> ``"accept"``/``"reject"``) to ``p``.
130
+
131
+ Returns True when a decision removes the paragraph's mark: the caller joins ``p``
132
+ with the paragraph after it (or keeps ``p`` if there is none).
133
+ """
134
+ ppr = p.find(PPR)
135
+ removed = _resolve_ppr(ppr, actions) if ppr is not None else False
136
+ _walk(p, actions)
137
+ return removed
138
+
139
+
140
+ def _resolve_ppr(ppr: ET.Element, actions: dict[int, str]) -> bool:
141
+ removed = False
142
+ rpr = ppr.find(RPR)
143
+ if rpr is not None:
144
+ emptied = False
145
+ for mark in list(rpr):
146
+ act = actions.get(id(mark))
147
+ if act is None or mark.tag not in _MARK_KIND:
148
+ continue
149
+ rpr.remove(mark)
150
+ emptied = True
151
+ if (_MARK_KIND[mark.tag], act) in _MARK_REMOVED:
152
+ removed = True
153
+ _resolve_rpr(rpr, actions)
154
+ if emptied and len(rpr) == 0:
155
+ ppr.remove(rpr)
156
+ change = ppr.find(PPR_CHANGE)
157
+ act = actions.get(id(change)) if change is not None else None
158
+ if change is not None and act is not None:
159
+ if act == REJECT:
160
+ old = change.find(PPR)
161
+ kept = [c for c in ppr if c.tag in (RPR, SECT_PR)]
162
+ ppr[:] = ([copy.deepcopy(c) for c in old] if old is not None else []) + kept
163
+ else:
164
+ ppr.remove(change)
165
+ return removed
166
+
167
+
168
+ def _resolve_rpr(rpr: ET.Element, actions: dict[int, str]) -> None:
169
+ change = rpr.find(RPR_CHANGE)
170
+ act = actions.get(id(change)) if change is not None else None
171
+ if change is None or act is None:
172
+ return
173
+ if act == REJECT:
174
+ old = change.find(RPR)
175
+ keep = [c for c in rpr if c.tag in _MARK_KIND] # revision marks of a paragraph mark
176
+ rpr[:] = keep + ([copy.deepcopy(c) for c in old] if old is not None else [])
177
+ else:
178
+ rpr.remove(change)
179
+
180
+
181
+ def _walk(node: ET.Element, actions: dict[int, str]) -> None:
182
+ i = 0
183
+ while i < len(node):
184
+ child = node[i]
185
+ tag = child.tag
186
+ if not isinstance(tag, str) or tag in (_TEXT_BOX, PPR):
187
+ i += 1
188
+ continue
189
+ if tag == RPR:
190
+ _resolve_rpr(child, actions)
191
+ i += 1
192
+ continue
193
+ act = actions.get(id(child)) if tag in _CONTENT_KIND else None
194
+ if act is None:
195
+ _walk(child, actions)
196
+ i += 1
197
+ continue
198
+ if (tag in _KEEPS_CONTENT_WHEN_ACCEPTED) == (act == ACCEPT):
199
+ _walk(child, actions)
200
+ content = list(child)
201
+ if tag in (DEL, MOVE_FROM):
202
+ for c in content:
203
+ _undelete(c)
204
+ node[i : i + 1] = content
205
+ i += len(content)
206
+ else:
207
+ left = _survivors(child)
208
+ node[i : i + 1] = left
209
+ i += len(left)
210
+
211
+
212
+ def _undelete(elem: ET.Element) -> None:
213
+ """Turn deleted text back into text, except inside a nested deletion."""
214
+ if elem.tag == DELTEXT:
215
+ elem.tag = T
216
+ elif elem.tag == _DEL_INSTR:
217
+ elem.tag = _INSTR
218
+ for c in elem:
219
+ if c.tag not in (DEL, MOVE_FROM):
220
+ _undelete(c)
221
+
222
+
223
+ def _survivors(elem: ET.Element) -> list[ET.Element]:
224
+ """What stays where removed content was: markers and comment-reference runs."""
225
+ out: list[ET.Element] = []
226
+ for c in elem:
227
+ tag = c.tag
228
+ if not isinstance(tag, str):
229
+ continue
230
+ if tag in _KEPT_MARKERS:
231
+ out.append(c)
232
+ elif tag == R:
233
+ if any(x.tag == _COMMENT_REFERENCE for x in c):
234
+ c[:] = [x for x in c if x.tag in (RPR, _COMMENT_REFERENCE)]
235
+ out.append(c)
236
+ elif tag.startswith(W) and tag != RPR:
237
+ out.extend(_survivors(c))
238
+ return out
239
+
240
+
241
+ MOVE_RANGE_TAGS = {
242
+ "from": (W + "moveFromRangeStart", W + "moveFromRangeEnd"),
243
+ "to": (W + "moveToRangeStart", W + "moveToRangeEnd"),
244
+ }
245
+
246
+
247
+ def drop_move_markers(p: ET.Element, sides: set[str]) -> bool:
248
+ """Remove the move range markers of ``sides`` (``"from"``, ``"to"``) from ``p``.
249
+
250
+ Called once no revision of that side is left in the document, so the markers
251
+ (matched by their ``w:id``, start and end alike) mark nothing any more. Returns
252
+ True if anything was removed.
253
+ """
254
+ tags = {t for side in sides for t in MOVE_RANGE_TAGS[side]}
255
+ changed = False
256
+
257
+ def walk(node: ET.Element) -> None:
258
+ nonlocal changed
259
+ for child in list(node):
260
+ if not isinstance(child.tag, str) or child.tag == _TEXT_BOX:
261
+ continue
262
+ if child.tag in tags:
263
+ node.remove(child)
264
+ changed = True
265
+ else:
266
+ walk(child)
267
+
268
+ walk(p)
269
+ return changed