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,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
|