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,280 @@
|
|
|
1
|
+
"""Comments and tracked revisions of a document, read-only.
|
|
2
|
+
|
|
3
|
+
Comments come from ``word/comments.xml`` (text, author, date) joined with
|
|
4
|
+
``word/commentsExtended.xml`` (reply parent, resolved state) and located by the
|
|
5
|
+
``w:commentRangeStart`` in the body. Revisions are found in the body paragraphs.
|
|
6
|
+
Both report the address of the paragraph they sit in.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import bisect
|
|
12
|
+
import re
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from doc_control.model.document import Document
|
|
17
|
+
from doc_control.model.parts import MS_REL_2011, OFFICE_REL, W14, W15, W, parse_part, related_part
|
|
18
|
+
|
|
19
|
+
COMMENTS_PART = "word/comments.xml"
|
|
20
|
+
COMMENTS_EXTENDED_PART = "word/commentsExtended.xml"
|
|
21
|
+
_RANGE_START = re.compile(rb"<(?:[\w.-]+:)?commentRangeStart\b[^>]*>")
|
|
22
|
+
_REFERENCE = re.compile(rb"<(?:[\w.-]+:)?commentReference\b[^>]*>")
|
|
23
|
+
_ID_ATTR = re.compile(rb"[\s:]id\s*=\s*[\"']([^\"']*)[\"']")
|
|
24
|
+
_TABLE_REVISION_TAG = re.compile(
|
|
25
|
+
rb"<(?:[\w.-]+:)?(?:cellIns|cellDel|cellMerge)[\s/>]"
|
|
26
|
+
rb"|<(?:[\w.-]+:)?trPr[\s>](?:(?!</(?:[\w.-]+:)?trPr>).)*?<(?:[\w.-]+:)?(?:ins|del)[\s/>]",
|
|
27
|
+
re.DOTALL,
|
|
28
|
+
)
|
|
29
|
+
_REVISION_TAG = re.compile(rb"<(?:[\w.-]+:)?(?:ins|del|moveFrom|moveTo|rPrChange|pPrChange)[\s/>]")
|
|
30
|
+
|
|
31
|
+
# Revision element -> reported type; the same tags inside a paragraph's own
|
|
32
|
+
# ``w:pPr/w:rPr`` mark the paragraph mark instead.
|
|
33
|
+
_RUN_REVISIONS = {
|
|
34
|
+
W + "ins": "insert",
|
|
35
|
+
W + "del": "delete",
|
|
36
|
+
W + "moveFrom": "move_from",
|
|
37
|
+
W + "moveTo": "move_to",
|
|
38
|
+
W + "rPrChange": "format",
|
|
39
|
+
}
|
|
40
|
+
_MARK_REVISIONS = {
|
|
41
|
+
W + "ins": "mark_insert",
|
|
42
|
+
W + "del": "mark_delete",
|
|
43
|
+
W + "moveFrom": "mark_move_from",
|
|
44
|
+
W + "moveTo": "mark_move_to",
|
|
45
|
+
}
|
|
46
|
+
_ROW_REVISIONS = {W + "ins": "row_insert", W + "del": "row_delete"}
|
|
47
|
+
_CELL_REVISIONS = {
|
|
48
|
+
W + "cellIns": "cell_insert",
|
|
49
|
+
W + "cellDel": "cell_delete",
|
|
50
|
+
W + "cellMerge": "cell_merge",
|
|
51
|
+
}
|
|
52
|
+
REVISION_TYPES = tuple(
|
|
53
|
+
sorted(
|
|
54
|
+
{
|
|
55
|
+
*_RUN_REVISIONS.values(),
|
|
56
|
+
*_MARK_REVISIONS.values(),
|
|
57
|
+
*_ROW_REVISIONS.values(),
|
|
58
|
+
*_CELL_REVISIONS.values(),
|
|
59
|
+
"paragraph_format",
|
|
60
|
+
"mark_format",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Comment:
|
|
68
|
+
id: str
|
|
69
|
+
author: str
|
|
70
|
+
date: str
|
|
71
|
+
initials: str
|
|
72
|
+
text: str
|
|
73
|
+
resolved: bool
|
|
74
|
+
reply_to: str | None
|
|
75
|
+
at: str | None # address of the paragraph where the comment range starts
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Revision:
|
|
80
|
+
id: str
|
|
81
|
+
type: str
|
|
82
|
+
author: str
|
|
83
|
+
date: str
|
|
84
|
+
at: str
|
|
85
|
+
text: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _text(elem: ET.Element, tags: tuple[str, ...]) -> str:
|
|
89
|
+
return "".join(t.text or "" for t in elem.iter() if t.tag in tags)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _comment_locations(doc: Document) -> dict[str, str]:
|
|
93
|
+
"""Comment id -> address of the paragraph where its range starts.
|
|
94
|
+
|
|
95
|
+
A range start at body level (between paragraphs) belongs to the next paragraph.
|
|
96
|
+
Without a start, the comment reference's paragraph is used.
|
|
97
|
+
"""
|
|
98
|
+
data = doc.index.spans.data
|
|
99
|
+
spans = doc.index.spans.paragraphs
|
|
100
|
+
starts = [spans[p.span_index].start for p in doc.index.paragraphs]
|
|
101
|
+
out: dict[str, str] = {}
|
|
102
|
+
for pattern in (_RANGE_START, _REFERENCE):
|
|
103
|
+
for m in pattern.finditer(data):
|
|
104
|
+
cid = _ID_ATTR.search(m.group(0))
|
|
105
|
+
if cid is None:
|
|
106
|
+
continue
|
|
107
|
+
i = bisect.bisect_right(starts, m.start()) - 1
|
|
108
|
+
if i >= 0 and m.start() < spans[doc.index.paragraphs[i].span_index].end:
|
|
109
|
+
para = doc.index.paragraphs[i]
|
|
110
|
+
elif i + 1 < len(starts):
|
|
111
|
+
para = doc.index.paragraphs[i + 1]
|
|
112
|
+
else:
|
|
113
|
+
continue
|
|
114
|
+
out.setdefault(cid.group(1).decode(), str(para.address))
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_comments(doc: Document) -> list[Comment]:
|
|
119
|
+
"""All comments in thread order: each root comment followed by its replies."""
|
|
120
|
+
name = related_part(doc.pkg, OFFICE_REL + "comments", COMMENTS_PART)
|
|
121
|
+
if name is None:
|
|
122
|
+
return []
|
|
123
|
+
ext_name = related_part(doc.pkg, MS_REL_2011 + "commentsExtended", COMMENTS_EXTENDED_PART)
|
|
124
|
+
parent_para: dict[str, str | None] = {} # paraId -> parent paraId
|
|
125
|
+
done: dict[str, bool] = {}
|
|
126
|
+
if ext_name is not None:
|
|
127
|
+
for ex in parse_part(doc.pkg, ext_name).iter(W15 + "commentEx"):
|
|
128
|
+
para_id = ex.get(W15 + "paraId")
|
|
129
|
+
if para_id is not None:
|
|
130
|
+
parent_para[para_id] = ex.get(W15 + "paraIdParent")
|
|
131
|
+
done[para_id] = (ex.get(W15 + "done") or "0").lower() in ("1", "true", "on")
|
|
132
|
+
locations = _comment_locations(doc)
|
|
133
|
+
rows: list[tuple[str, str | None, Comment]] = [] # (own paraId, parent paraId, comment)
|
|
134
|
+
para_to_id: dict[str, str] = {}
|
|
135
|
+
for c in parse_part(doc.pkg, name).findall(W + "comment"):
|
|
136
|
+
cid = c.get(W + "id")
|
|
137
|
+
if cid is None:
|
|
138
|
+
continue
|
|
139
|
+
paragraphs = c.findall(W + "p")
|
|
140
|
+
last_para = paragraphs[-1].get(W14 + "paraId") if paragraphs else None
|
|
141
|
+
text = "\n".join(_text(p, (W + "t",)) for p in paragraphs)
|
|
142
|
+
own = last_para or ""
|
|
143
|
+
if own:
|
|
144
|
+
para_to_id[own] = cid
|
|
145
|
+
rows.append(
|
|
146
|
+
(
|
|
147
|
+
own,
|
|
148
|
+
parent_para.get(own),
|
|
149
|
+
Comment(
|
|
150
|
+
id=cid,
|
|
151
|
+
author=c.get(W + "author") or "",
|
|
152
|
+
date=c.get(W + "date") or "",
|
|
153
|
+
initials=c.get(W + "initials") or "",
|
|
154
|
+
text=text,
|
|
155
|
+
resolved=done.get(own, False),
|
|
156
|
+
reply_to=None,
|
|
157
|
+
at=locations.get(cid),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
roots: list[Comment] = []
|
|
162
|
+
replies: dict[str, list[Comment]] = {}
|
|
163
|
+
for _, parent, comment in rows:
|
|
164
|
+
parent_id = para_to_id.get(parent) if parent else None
|
|
165
|
+
if parent_id is None or parent_id == comment.id:
|
|
166
|
+
roots.append(comment)
|
|
167
|
+
else:
|
|
168
|
+
reply = Comment(**{**comment.__dict__, "reply_to": parent_id})
|
|
169
|
+
replies.setdefault(parent_id, []).append(reply)
|
|
170
|
+
out: list[Comment] = []
|
|
171
|
+
placed: set[str] = set()
|
|
172
|
+
|
|
173
|
+
def place(comment: Comment) -> None:
|
|
174
|
+
if comment.id in placed:
|
|
175
|
+
return
|
|
176
|
+
placed.add(comment.id)
|
|
177
|
+
out.append(comment)
|
|
178
|
+
for reply in replies.get(comment.id, []):
|
|
179
|
+
place(reply)
|
|
180
|
+
|
|
181
|
+
for root in roots:
|
|
182
|
+
place(root)
|
|
183
|
+
for reply_list in replies.values(): # replies whose chain never reached a root
|
|
184
|
+
for reply in reply_list:
|
|
185
|
+
place(reply)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def read_revisions(doc: Document) -> list[Revision]:
|
|
190
|
+
"""Tracked changes of the main story in document order.
|
|
191
|
+
|
|
192
|
+
A table row or cell revision is reported at the first paragraph of the row or
|
|
193
|
+
cell, before the revisions of that paragraph.
|
|
194
|
+
"""
|
|
195
|
+
keyed: list[tuple[int, int, Revision]] = [] # (paragraph number, 0 row/cell | 1 text, rev)
|
|
196
|
+
for para in doc.index.paragraphs:
|
|
197
|
+
if not _REVISION_TAG.search(doc.paragraph_bytes(para)):
|
|
198
|
+
continue
|
|
199
|
+
found: list[Revision] = []
|
|
200
|
+
_collect(doc.index.spans.parse_paragraph(para.span_index), str(para.address), found)
|
|
201
|
+
keyed += [(para.number, 1, r) for r in found]
|
|
202
|
+
data = doc.index.spans.data
|
|
203
|
+
if _TABLE_REVISION_TAG.search(data):
|
|
204
|
+
keyed += _table_revisions(doc)
|
|
205
|
+
keyed.sort(key=lambda k: (k[0], k[1]))
|
|
206
|
+
return [r for _, _, r in keyed]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _table_revisions(doc: Document) -> list[tuple[int, int, Revision]]:
|
|
210
|
+
"""Row and cell revisions, found by walking the whole body counting paragraphs."""
|
|
211
|
+
root = ET.fromstring(doc.index.spans.data)
|
|
212
|
+
count = 0
|
|
213
|
+
out: list[tuple[int, int, Revision]] = []
|
|
214
|
+
|
|
215
|
+
def at(number: int) -> str | None:
|
|
216
|
+
return str(doc.index.paragraph(number).address) if number <= len(doc.index) else None
|
|
217
|
+
|
|
218
|
+
def walk(elem: ET.Element) -> None:
|
|
219
|
+
nonlocal count
|
|
220
|
+
for child in elem:
|
|
221
|
+
if child.tag == W + "p":
|
|
222
|
+
count += 1 # numbered like the index: nested paragraphs are not entered
|
|
223
|
+
continue
|
|
224
|
+
if child.tag == W + "tr":
|
|
225
|
+
where = at(count + 1)
|
|
226
|
+
for mark in child.iterfind(f"{W}trPr/*"):
|
|
227
|
+
if mark.tag in _ROW_REVISIONS and where is not None:
|
|
228
|
+
out.append(
|
|
229
|
+
(count + 1, 0, _revision(mark, _ROW_REVISIONS[mark.tag], where, ""))
|
|
230
|
+
)
|
|
231
|
+
elif child.tag == W + "tc":
|
|
232
|
+
where = at(count + 1)
|
|
233
|
+
for mark in child.iterfind(f"{W}tcPr/*"):
|
|
234
|
+
if mark.tag in _CELL_REVISIONS and where is not None:
|
|
235
|
+
out.append(
|
|
236
|
+
(count + 1, 0, _revision(mark, _CELL_REVISIONS[mark.tag], where, ""))
|
|
237
|
+
)
|
|
238
|
+
if isinstance(child.tag, str):
|
|
239
|
+
walk(child)
|
|
240
|
+
|
|
241
|
+
walk(root)
|
|
242
|
+
if count != len(doc.index): # numbering disagrees with the index: do not guess
|
|
243
|
+
return []
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _collect(elem: ET.Element, at: str, out: list[Revision]) -> None:
|
|
248
|
+
for child in elem:
|
|
249
|
+
tag = child.tag
|
|
250
|
+
if not isinstance(tag, str) or tag == W + "txbxContent":
|
|
251
|
+
continue
|
|
252
|
+
if tag == W + "pPr":
|
|
253
|
+
for mark in child.iterfind(f"{W}rPr/*"):
|
|
254
|
+
if mark.tag in _MARK_REVISIONS:
|
|
255
|
+
out.append(_revision(mark, _MARK_REVISIONS[mark.tag], at, ""))
|
|
256
|
+
elif mark.tag == W + "rPrChange":
|
|
257
|
+
out.append(_revision(mark, "mark_format", at, ""))
|
|
258
|
+
change = child.find(W + "pPrChange")
|
|
259
|
+
if change is not None:
|
|
260
|
+
out.append(_revision(change, "paragraph_format", at, ""))
|
|
261
|
+
continue
|
|
262
|
+
if tag in _RUN_REVISIONS:
|
|
263
|
+
kind = _RUN_REVISIONS[tag]
|
|
264
|
+
if kind in ("insert", "move_to"): # content later deleted by someone else still counts
|
|
265
|
+
text = _text(child, (W + "t", W + "delText"))
|
|
266
|
+
else:
|
|
267
|
+
text = _text(child, (W + "delText",)) if kind != "format" else ""
|
|
268
|
+
out.append(_revision(child, kind, at, text))
|
|
269
|
+
_collect(child, at, out)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _revision(elem: ET.Element, kind: str, at: str, text: str) -> Revision:
|
|
273
|
+
return Revision(
|
|
274
|
+
id=elem.get(W + "id") or "",
|
|
275
|
+
type=kind,
|
|
276
|
+
author=elem.get(W + "author") or "",
|
|
277
|
+
date=elem.get(W + "date") or "",
|
|
278
|
+
at=at,
|
|
279
|
+
text=text,
|
|
280
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""A document opened for reading: the package, its paragraph index and styles.
|
|
2
|
+
|
|
3
|
+
Nothing here writes. Derived tables (styles, per-paragraph style ids) are built
|
|
4
|
+
on first use and cached for the life of the object, which is one operation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from functools import cached_property
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from doc_control.model.index import DocumentIndex, Paragraph, build_index
|
|
16
|
+
from doc_control.model.parts import W
|
|
17
|
+
from doc_control.model.styles import StyleTable, load_styles
|
|
18
|
+
from doc_control.ooxml.zipio import Package
|
|
19
|
+
|
|
20
|
+
DOCUMENT_PART = "word/document.xml"
|
|
21
|
+
_TC_OPEN = re.compile(rb"<(?:[\w.-]+:)?tc[\s>]")
|
|
22
|
+
_TC_CLOSE = re.compile(rb"</(?:[\w.-]+:)?tc\s*>")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ParaProps:
|
|
27
|
+
"""The properties of a paragraph that decide its style and heading level."""
|
|
28
|
+
|
|
29
|
+
style: str | None # explicit ``w:pStyle``, else None
|
|
30
|
+
outline_level: int | None # explicit ``w:outlineLvl`` (0-9), else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Document:
|
|
34
|
+
def __init__(self, path: str) -> None:
|
|
35
|
+
self.path = path
|
|
36
|
+
self.pkg = Package(Path(path))
|
|
37
|
+
self.index: DocumentIndex = build_index(self.pkg.read(DOCUMENT_PART))
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def sha256(self) -> str:
|
|
41
|
+
return self.pkg.sha256
|
|
42
|
+
|
|
43
|
+
def envelope_doc(self) -> dict[str, Any]:
|
|
44
|
+
"""The ``doc`` block of a response."""
|
|
45
|
+
return {"path": self.path, "sha256": self.sha256, "paragraphs": len(self.index)}
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def styles(self) -> StyleTable:
|
|
49
|
+
return load_styles(self.pkg)
|
|
50
|
+
|
|
51
|
+
@cached_property
|
|
52
|
+
def props(self) -> tuple[ParaProps, ...]:
|
|
53
|
+
out: list[ParaProps] = []
|
|
54
|
+
spans = self.index.spans
|
|
55
|
+
for para in self.index.paragraphs:
|
|
56
|
+
elem = spans.parse_paragraph(para.span_index)
|
|
57
|
+
ppr = elem.find(W + "pPr")
|
|
58
|
+
style = level = None
|
|
59
|
+
if ppr is not None:
|
|
60
|
+
pstyle = ppr.find(W + "pStyle")
|
|
61
|
+
style = pstyle.get(W + "val") if pstyle is not None else None
|
|
62
|
+
outline = ppr.find(W + "outlineLvl")
|
|
63
|
+
raw = outline.get(W + "val") if outline is not None else None
|
|
64
|
+
level = int(raw) if raw is not None and raw.isdigit() else None
|
|
65
|
+
out.append(ParaProps(style, level))
|
|
66
|
+
return tuple(out)
|
|
67
|
+
|
|
68
|
+
@cached_property
|
|
69
|
+
def cells(self) -> tuple[int, ...]:
|
|
70
|
+
"""Per paragraph, the byte offset of its innermost table cell (-1: not in a cell)."""
|
|
71
|
+
data = self.index.spans.data
|
|
72
|
+
events = sorted(
|
|
73
|
+
[(m.start(), True) for m in _TC_OPEN.finditer(data)]
|
|
74
|
+
+ [(m.start(), False) for m in _TC_CLOSE.finditer(data)]
|
|
75
|
+
)
|
|
76
|
+
spans = self.index.spans.paragraphs
|
|
77
|
+
out: list[int] = []
|
|
78
|
+
stack: list[int] = []
|
|
79
|
+
e = 0
|
|
80
|
+
for para in self.index.paragraphs:
|
|
81
|
+
start = spans[para.span_index].start
|
|
82
|
+
while e < len(events) and events[e][0] < start:
|
|
83
|
+
pos, opens = events[e]
|
|
84
|
+
if opens:
|
|
85
|
+
stack.append(pos)
|
|
86
|
+
elif stack:
|
|
87
|
+
stack.pop()
|
|
88
|
+
e += 1
|
|
89
|
+
out.append(stack[-1] if stack else -1)
|
|
90
|
+
return tuple(out)
|
|
91
|
+
|
|
92
|
+
def style_id(self, para: Paragraph) -> str | None:
|
|
93
|
+
"""The paragraph's style id: explicit, else the document's default paragraph style."""
|
|
94
|
+
return self.props[para.number - 1].style or self.styles.default_paragraph
|
|
95
|
+
|
|
96
|
+
def heading_level(self, para: Paragraph) -> int | None:
|
|
97
|
+
"""1-9 when the paragraph is a heading (direct outline level, else its style's)."""
|
|
98
|
+
props = self.props[para.number - 1]
|
|
99
|
+
if props.outline_level is not None:
|
|
100
|
+
return props.outline_level + 1 if props.outline_level <= 8 else None
|
|
101
|
+
return self.styles.heading_level(props.style)
|
|
102
|
+
|
|
103
|
+
def paragraph_bytes(self, para: Paragraph) -> bytes:
|
|
104
|
+
return self.index.spans.paragraph_bytes(para.span_index)
|
|
105
|
+
|
|
106
|
+
def count_elements(self, local_name: str) -> int:
|
|
107
|
+
"""How many ``<…:local_name>`` start tags ``word/document.xml`` holds (any prefix)."""
|
|
108
|
+
pattern = rb"<(?:[\w.-]+:)?" + re.escape(local_name.encode()) + rb"[\s/>]"
|
|
109
|
+
return len(re.findall(pattern, self.index.spans.data))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Summaries of run and paragraph properties (``w:rPr`` / ``w:pPr``) as plain dicts.
|
|
2
|
+
|
|
3
|
+
Only properties that are present are reported, so an empty dict means "inherits
|
|
4
|
+
everything". Lengths stay in the units OOXML uses (twips), sizes are in points.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from doc_control.model.parts import W
|
|
13
|
+
|
|
14
|
+
_OFF = frozenset({"0", "false", "off", "none"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _val(elem: ET.Element | None) -> str | None:
|
|
18
|
+
return None if elem is None else elem.get(W + "val")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _toggle(props: ET.Element, name: str) -> bool | None:
|
|
22
|
+
elem = props.find(W + name)
|
|
23
|
+
if elem is None:
|
|
24
|
+
return None
|
|
25
|
+
return (elem.get(W + "val") or "1").lower() not in _OFF
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _int(text: str | None) -> int | None:
|
|
29
|
+
try:
|
|
30
|
+
return int(text) if text is not None else None
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_format(rpr: ET.Element | None) -> dict[str, Any]:
|
|
36
|
+
"""Character formatting set by a ``w:rPr``."""
|
|
37
|
+
out: dict[str, Any] = {}
|
|
38
|
+
if rpr is None:
|
|
39
|
+
return out
|
|
40
|
+
if (style := _val(rpr.find(W + "rStyle"))) is not None:
|
|
41
|
+
out["style"] = style
|
|
42
|
+
for key, tag in (
|
|
43
|
+
("bold", "b"),
|
|
44
|
+
("italic", "i"),
|
|
45
|
+
("small_caps", "smallCaps"),
|
|
46
|
+
("strike", "strike"),
|
|
47
|
+
):
|
|
48
|
+
if (flag := _toggle(rpr, tag)) is not None:
|
|
49
|
+
out[key] = flag
|
|
50
|
+
if (underline := _val(rpr.find(W + "u"))) is not None:
|
|
51
|
+
out["underline"] = underline
|
|
52
|
+
fonts = rpr.find(W + "rFonts")
|
|
53
|
+
if fonts is not None:
|
|
54
|
+
font = fonts.get(W + "ascii") or fonts.get(W + "hAnsi")
|
|
55
|
+
if font:
|
|
56
|
+
out["font"] = font
|
|
57
|
+
if (half_points := _int(_val(rpr.find(W + "sz")))) is not None:
|
|
58
|
+
out["size_pt"] = half_points / 2
|
|
59
|
+
if (color := _val(rpr.find(W + "color"))) is not None:
|
|
60
|
+
out["color"] = color
|
|
61
|
+
if (highlight := _val(rpr.find(W + "highlight"))) is not None:
|
|
62
|
+
out["highlight"] = highlight
|
|
63
|
+
if (align := _val(rpr.find(W + "vertAlign"))) is not None:
|
|
64
|
+
out["vertical_align"] = align
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def para_format(ppr: ET.Element | None) -> dict[str, Any]:
|
|
69
|
+
"""Paragraph formatting set by a ``w:pPr`` (its ``w:rPr`` is not included)."""
|
|
70
|
+
out: dict[str, Any] = {}
|
|
71
|
+
if ppr is None:
|
|
72
|
+
return out
|
|
73
|
+
if (align := _val(ppr.find(W + "jc"))) is not None:
|
|
74
|
+
out["alignment"] = align
|
|
75
|
+
ind = ppr.find(W + "ind")
|
|
76
|
+
if ind is not None:
|
|
77
|
+
indent: dict[str, int] = {}
|
|
78
|
+
for key, names in (
|
|
79
|
+
("left", ("left", "start")),
|
|
80
|
+
("right", ("right", "end")),
|
|
81
|
+
("first_line", ("firstLine",)),
|
|
82
|
+
("hanging", ("hanging",)),
|
|
83
|
+
):
|
|
84
|
+
for name in names:
|
|
85
|
+
if (value := _int(ind.get(W + name))) is not None:
|
|
86
|
+
indent[key] = value
|
|
87
|
+
break
|
|
88
|
+
if indent:
|
|
89
|
+
out["indent"] = indent
|
|
90
|
+
spacing = ppr.find(W + "spacing")
|
|
91
|
+
if spacing is not None:
|
|
92
|
+
space: dict[str, Any] = {}
|
|
93
|
+
for key in ("before", "after", "line"):
|
|
94
|
+
if (value := _int(spacing.get(W + key))) is not None:
|
|
95
|
+
space[key] = value
|
|
96
|
+
if (rule := spacing.get(W + "lineRule")) is not None:
|
|
97
|
+
space["line_rule"] = rule
|
|
98
|
+
if space:
|
|
99
|
+
out["spacing"] = space
|
|
100
|
+
for key, tag in (
|
|
101
|
+
("keep_next", "keepNext"),
|
|
102
|
+
("keep_lines", "keepLines"),
|
|
103
|
+
("page_break_before", "pageBreakBefore"),
|
|
104
|
+
):
|
|
105
|
+
if (flag := _toggle(ppr, tag)) is not None:
|
|
106
|
+
out[key] = flag
|
|
107
|
+
if (level := _int(_val(ppr.find(W + "outlineLvl")))) is not None:
|
|
108
|
+
out["outline_level"] = level
|
|
109
|
+
return out
|