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