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,418 @@
1
+ """Structure detection: chapters and scenes with stable ids, signals and overrides.
2
+
3
+ :func:`detect` is a pure function of per-paragraph facts (:class:`Para`), so the same
4
+ document always gives the same structure and the layout facts can come from anywhere;
5
+ :func:`paragraphs_of` reads them from an open :class:`~doc_control.model.document.Document`.
6
+
7
+ Chapters
8
+ A non-blank paragraph starts a chapter when it carries a *primary* signal:
9
+
10
+ ``heading_style`` a heading style of level ``<= profile.chapter_level``
11
+ ``outline_level`` a direct outline level of level ``<= profile.chapter_level``
12
+ ``chapter_word`` ``Capítulo 3``, ``CHAPTER IV. Title``, ``Parte II``
13
+ ``section_name`` ``Prólogo``, ``Epílogo``, ``Introducción``
14
+ ``numeral`` a bare number or Roman numeral, only when also centred, after a
15
+ page break or in a heading style (a lone ``3`` is often just text)
16
+ ``override`` an override forced it
17
+
18
+ Supporting signals ride along: ``centred``, ``page_break_before``, ``blank_lines``
19
+ (empty paragraphs just before). A chapter whose text after the heading is shorter than
20
+ ``profile.min_chapter_words`` is not a chapter (``suppressed``), unless it is forced or
21
+ is a ``part`` heading, which holds chapters rather than text.
22
+
23
+ Scenes
24
+ A scene starts at a chapter start, at a scene-break paragraph (``scene_marker``), or
25
+ after ``profile.scene_blank_lines`` or more empty paragraphs (``blank_lines``; off by
26
+ default). A break with no text on one side of it (next to a heading, another break or
27
+ the end of the chapter) opens no scene.
28
+
29
+ Overrides
30
+ ``{fp, kind, action, nth}``: ``force`` or ``deny`` a ``chapter`` or ``scene`` at every
31
+ paragraph whose fingerprint is ``fp`` (or only the ``nth`` of them, counting from 1).
32
+ They are keyed by fingerprint, not position, so they survive edits elsewhere; one that
33
+ matches nothing (its paragraph was edited or removed) is reported in
34
+ ``unmatched_overrides`` rather than guessed at.
35
+
36
+ Stable ids
37
+ ``ch-<8 hex>`` hashes the heading's fingerprint and how many earlier chapters share it;
38
+ ``sc-<8 hex>`` hashes the chapter id and the fingerprint of the scene's first text
39
+ paragraph (the one after its break), plus a count only when two scenes of a chapter
40
+ begin with identical text; a chapter's first scene is keyed on the chapter alone.
41
+ Neither depends on a position, a
42
+ count of paragraphs or any other chapter's text, so editing or inserting text elsewhere
43
+ leaves them unchanged. Editing a chapter's own heading changes its id (and its scenes').
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import hashlib
49
+ from collections import defaultdict
50
+ from collections.abc import Sequence
51
+ from dataclasses import dataclass, field
52
+ from typing import Literal
53
+
54
+ from doc_control.model.document import Document
55
+ from doc_control.model.layout import _break_only, blank_before, centred, is_empty, page_break_before
56
+ from doc_control.model.segment import (
57
+ ChapterHeading,
58
+ is_scene_break,
59
+ number_word,
60
+ parse_chapter_heading,
61
+ )
62
+
63
+ Kind = Literal["chapter", "scene"]
64
+ Action = Literal["force", "deny"]
65
+ KINDS: tuple[str, ...] = ("chapter", "scene")
66
+ ACTIONS: tuple[str, ...] = ("force", "deny")
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Para:
71
+ """What detection needs to know about one paragraph (``number`` is 1-based)."""
72
+
73
+ number: int
74
+ text: str
75
+ fp: str
76
+ heading_level: int | None = None # 1-9 when styled or outlined as a heading
77
+ heading_source: str | None = None # "outline_level" or "heading_style"
78
+ centred: bool = False
79
+ page_break_before: bool = False
80
+ blank_before: int | None = None # empty paragraphs just above; None = count from the texts
81
+ empty: bool | None = None # None = no visible text
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Profile:
86
+ """Per-document detection settings."""
87
+
88
+ min_chapter_words: int = 0
89
+ chapter_level: int = 1
90
+ scene_blank_lines: int = 0 # 0 = empty paragraphs never start a scene
91
+ heading_styles: bool = True
92
+ chapter_patterns: bool = True
93
+ scene_markers: bool = True
94
+ language: str = "es" # "es" accepts a lone "I" as a numeral; others need more signals
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class Override:
99
+ fp: str
100
+ kind: Kind
101
+ action: Action
102
+ nth: int | None = None
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class DetectedChapter:
107
+ id: str
108
+ first: int # paragraph numbers, inclusive
109
+ last: int
110
+ kind: str # chapter, part, prologue, epilogue, introduction, numeral
111
+ number: int | None
112
+ words: int # words after the heading
113
+ signals: tuple[str, ...]
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class DetectedScene:
118
+ id: str
119
+ chapter: str | None
120
+ first: int
121
+ last: int
122
+ words: int
123
+ signals: tuple[str, ...]
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Structure:
128
+ chapters: tuple[DetectedChapter, ...]
129
+ scenes: tuple[DetectedScene, ...]
130
+ suppressed: tuple[tuple[int, Kind, str], ...] # (paragraph, kind, reason)
131
+ unmatched_overrides: tuple[Override, ...] = field(default=())
132
+
133
+
134
+ def paragraphs_of(doc: Document) -> list[Para]:
135
+ """The detection facts of every paragraph of ``doc``, from :mod:`doc_control.model.layout`."""
136
+ out: list[Para] = []
137
+ pending = False # a page break was seen and only empty paragraphs have followed
138
+ for para in doc.index.paragraphs:
139
+ props = doc.props[para.number - 1]
140
+ level = doc.heading_level(para)
141
+ source = None
142
+ if level is not None:
143
+ source = "outline_level" if props.outline_level is not None else "heading_style"
144
+ empty = is_empty(doc, para)
145
+ breaks = page_break_before(doc, para) or pending
146
+ pending = empty and (breaks or _break_only(doc, para))
147
+ out.append(
148
+ Para(
149
+ para.number,
150
+ para.text,
151
+ para.fp,
152
+ level,
153
+ source,
154
+ centred(doc, para),
155
+ breaks,
156
+ blank_before(doc, para),
157
+ empty,
158
+ )
159
+ )
160
+ return out
161
+
162
+
163
+ def _words(text: str) -> int:
164
+ return len(text.split())
165
+
166
+
167
+ def _id(prefix: str, *parts: str | int) -> str:
168
+ data = "\0".join(str(p) for p in (prefix, *parts)).encode()
169
+ return f"{'ch' if prefix == 'chapter' else 'sc'}-{hashlib.blake2s(data).hexdigest()[:8]}"
170
+
171
+
172
+ def _unique(prefix: str, key: tuple[str, ...], seen: dict[tuple[str, ...], int]) -> str:
173
+ """The id for ``key``, counting earlier uses of the same key."""
174
+ count = seen[key]
175
+ seen[key] += 1
176
+ return _id(prefix, *key, count)
177
+
178
+
179
+ def detect(
180
+ paras: Sequence[Para],
181
+ profile: Profile = Profile(), # noqa: B008 - frozen, shared on purpose
182
+ overrides: Sequence[Override] = (),
183
+ ) -> Structure:
184
+ n = len(paras)
185
+ # A heading with no text (auto-numbered) is a heading, not a blank line.
186
+ blank = [
187
+ (p.empty if p.empty is not None else not p.text.strip()) and p.heading_level is None
188
+ for p in paras
189
+ ]
190
+ blank_before = [0] * n
191
+ run = 0
192
+ for i, para in enumerate(paras):
193
+ blank_before[i] = para.blank_before if para.blank_before is not None else run
194
+ if para.blank_before is None and blank[i]:
195
+ run += 1
196
+ elif para.blank_before is None:
197
+ run = 0
198
+
199
+ actions, unmatched = _resolve_overrides(paras, blank, overrides)
200
+ suppressed: list[tuple[int, Kind, str]] = []
201
+ chapters = _chapter_starts(paras, blank, blank_before, profile, actions, suppressed)
202
+ starts = sorted(chapters)
203
+
204
+ scene_opener_signals = _scene_openers(paras, blank, blank_before, profile, actions, chapters)
205
+ for i in scene_opener_signals:
206
+ if "denied" in scene_opener_signals[i]:
207
+ suppressed.append((paras[i].number, "scene", "override"))
208
+ scene_opener_signals = {i: s for i, s in scene_opener_signals.items() if "denied" not in s}
209
+
210
+ def content(i: int) -> bool:
211
+ text = paras[i].text
212
+ return bool(text.strip()) and i not in chapters and not is_scene_break(text)
213
+
214
+ def words(lo: int, hi: int) -> int:
215
+ return sum(_words(paras[i].text) for i in range(lo, hi) if content(i))
216
+
217
+ # Chapters below the minimum word count fall back into the text before them.
218
+ kept: list[int] = []
219
+ for pos, i in enumerate(starts):
220
+ end = starts[pos + 1] if pos + 1 < len(starts) else n
221
+ signals, heading = chapters[i]
222
+ body = words(i + 1, end)
223
+ if (
224
+ profile.min_chapter_words
225
+ and body < profile.min_chapter_words
226
+ and "override" not in signals
227
+ and heading.kind != "part"
228
+ ):
229
+ suppressed.append((paras[i].number, "chapter", "below_minimum_words"))
230
+ continue
231
+ kept.append(i)
232
+ kept_set = set(kept)
233
+
234
+ detected_chapters: list[DetectedChapter] = []
235
+ chapter_ids: dict[int, str] = {}
236
+ seen_chapters: dict[tuple[str, ...], int] = defaultdict(int)
237
+ for pos, i in enumerate(kept):
238
+ end = kept[pos + 1] if pos + 1 < len(kept) else n
239
+ signals, heading = chapters[i]
240
+ cid = _unique("chapter", (paras[i].fp,), seen_chapters)
241
+ chapter_ids[i] = cid
242
+ detected_chapters.append(
243
+ DetectedChapter(
244
+ cid,
245
+ paras[i].number,
246
+ paras[end - 1].number,
247
+ heading.kind,
248
+ heading.number,
249
+ words(i + 1, end),
250
+ tuple(signals),
251
+ )
252
+ )
253
+
254
+ # Scenes: regions are the text before the first chapter, then one per chapter.
255
+ regions: list[tuple[str | None, int, int]] = []
256
+ first_text = next((i for i in range(n) if not blank[i]), n)
257
+ lead_end = kept[0] if kept else n
258
+ if first_text < lead_end:
259
+ regions.append((None, first_text, lead_end))
260
+ for pos, i in enumerate(kept):
261
+ regions.append((chapter_ids[i], i, kept[pos + 1] if pos + 1 < len(kept) else n))
262
+
263
+ scenes: list[DetectedScene] = []
264
+ for owner, lo, hi in regions:
265
+ openers = [i for i in sorted(scene_opener_signals) if lo < i < hi and i not in kept_set]
266
+ taken = [lo]
267
+ for pos, o in enumerate(openers):
268
+ forced = "override" in scene_opener_signals[o]
269
+ nxt = openers[pos + 1] if pos + 1 < len(openers) else hi
270
+ before = any(content(i) for i in range(taken[-1], o))
271
+ after = any(content(i) for i in range(o, nxt))
272
+ if forced or (before and after):
273
+ taken.append(o)
274
+ else:
275
+ suppressed.append((paras[o].number, "scene", "no_text_between"))
276
+ seen_scenes: dict[tuple[str, ...], int] = defaultdict(int)
277
+ for pos, o in enumerate(taken):
278
+ end = taken[pos + 1] if pos + 1 < len(taken) else hi
279
+ if o == lo:
280
+ opening: tuple[str, ...] = (
281
+ ("chapter_start",) if lo in kept_set else ("document_start",)
282
+ )
283
+ else:
284
+ opening = tuple(scene_opener_signals[o])
285
+ if o == lo:
286
+ key = "start"
287
+ else: # the first text of the scene, so adding scenes elsewhere moves no id
288
+ text_at = next((i for i in range(o, end) if content(i)), o)
289
+ key = paras[text_at].fp
290
+ sid = _unique("scene", (owner or "-", key), seen_scenes)
291
+ scenes.append(
292
+ DetectedScene(
293
+ sid, owner, paras[o].number, paras[end - 1].number, words(o, end), opening
294
+ )
295
+ )
296
+ suppressed.sort()
297
+ return Structure(tuple(detected_chapters), tuple(scenes), tuple(suppressed), unmatched)
298
+
299
+
300
+ def _resolve_overrides(
301
+ paras: Sequence[Para], blank: Sequence[bool], overrides: Sequence[Override]
302
+ ) -> tuple[dict[tuple[str, int], str], tuple[Override, ...]]:
303
+ by_fp: dict[str, list[int]] = defaultdict(list)
304
+ for i, para in enumerate(paras):
305
+ if not blank[i]:
306
+ by_fp[para.fp].append(i)
307
+ actions: dict[tuple[str, int], str] = {}
308
+ unmatched: list[Override] = []
309
+ for o in overrides:
310
+ hits = by_fp.get(o.fp, [])
311
+ if o.nth is not None:
312
+ hits = hits[o.nth - 1 : o.nth]
313
+ for i in hits:
314
+ actions[(o.kind, i)] = o.action # a later override wins
315
+ if not hits:
316
+ unmatched.append(o)
317
+ return actions, tuple(unmatched)
318
+
319
+
320
+ def _chapter_starts(
321
+ paras: Sequence[Para],
322
+ blank: Sequence[bool],
323
+ blank_before: Sequence[int],
324
+ profile: Profile,
325
+ actions: dict[tuple[str, int], str],
326
+ suppressed: list[tuple[int, Kind, str]],
327
+ ) -> dict[int, tuple[list[str], ChapterHeading]]:
328
+ found: dict[int, tuple[list[str], ChapterHeading]] = {}
329
+ for i, para in enumerate(paras):
330
+ if blank[i]:
331
+ continue
332
+ primary = _primary_signals(para, profile)
333
+ action = actions.get(("chapter", i))
334
+ if action == "deny":
335
+ if primary:
336
+ suppressed.append((para.number, "chapter", "override"))
337
+ continue
338
+ if action == "force":
339
+ primary = ["override", *primary]
340
+ elif not primary:
341
+ continue
342
+ signals = [*primary]
343
+ if para.centred:
344
+ signals.append("centred")
345
+ if para.page_break_before:
346
+ signals.append("page_break_before")
347
+ if blank_before[i]:
348
+ signals.append("blank_lines")
349
+ heading = parse_chapter_heading(para.text)
350
+ if heading is None and (value := number_word(para.text)) is not None:
351
+ heading = ChapterHeading("numeral", value, "")
352
+ heading = heading or ChapterHeading("chapter", None, "")
353
+ found[i] = (signals, heading)
354
+ return found
355
+
356
+
357
+ def _primary_signals(para: Para, profile: Profile) -> list[str]:
358
+ if is_scene_break(para.text):
359
+ return []
360
+ signals: list[str] = []
361
+ styled = (
362
+ profile.heading_styles
363
+ and para.heading_level is not None
364
+ and para.heading_level <= profile.chapter_level
365
+ )
366
+ if styled and para.heading_source:
367
+ signals.append(para.heading_source)
368
+ heading = parse_chapter_heading(para.text) if profile.chapter_patterns else None
369
+ laid_out = styled or para.centred or para.page_break_before
370
+ if heading is not None:
371
+ if heading.kind in ("chapter", "part"):
372
+ signals.append("chapter_word")
373
+ elif heading.kind == "numeral":
374
+ lone_i = para.text.strip().rstrip(".)") == "I"
375
+ if lone_i and profile.language != "es": # "I" is a pronoun in English
376
+ laid_out = styled or (para.centred and para.page_break_before)
377
+ if laid_out:
378
+ signals.append("numeral")
379
+ else:
380
+ signals.append("section_name")
381
+ elif profile.chapter_patterns and laid_out and number_word(para.text) is not None:
382
+ signals.append("numeral") # Uno, Dos… Veinte; Primera…
383
+ return signals
384
+
385
+
386
+ def _scene_openers(
387
+ paras: Sequence[Para],
388
+ blank: Sequence[bool],
389
+ blank_before: Sequence[int],
390
+ profile: Profile,
391
+ actions: dict[tuple[str, int], str],
392
+ chapters: dict[int, tuple[list[str], ChapterHeading]],
393
+ ) -> dict[int, list[str]]:
394
+ """Paragraphs that would open a scene; one a deny override removed carries ``denied``."""
395
+ out: dict[int, list[str]] = {}
396
+ for i, para in enumerate(paras):
397
+ if blank[i] or i in chapters:
398
+ continue
399
+ signals: list[str] = []
400
+ if profile.scene_markers and is_scene_break(para.text):
401
+ signals.append("scene_marker")
402
+ elif profile.scene_blank_lines and blank_before[i] >= profile.scene_blank_lines:
403
+ signals.append("blank_lines")
404
+ action = actions.get(("scene", i))
405
+ if action == "deny":
406
+ if signals:
407
+ out[i] = ["denied"]
408
+ continue
409
+ if action == "force":
410
+ signals.insert(0, "override")
411
+ elif not signals:
412
+ continue
413
+ if para.centred and "scene_marker" in signals:
414
+ signals.append("centred")
415
+ if blank_before[i] and "blank_lines" not in signals:
416
+ signals.append("blank_lines")
417
+ out[i] = signals
418
+ return out
@@ -0,0 +1,134 @@
1
+ """The style table of ``word/styles.xml``: ids, names, inheritance and heading levels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import xml.etree.ElementTree as ET
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from doc_control.model.format import para_format, run_format
11
+ from doc_control.model.parts import OFFICE_REL, W, parse_part, related_part
12
+ from doc_control.ooxml.zipio import Package
13
+
14
+ STYLES_PART = "word/styles.xml"
15
+ _HEADING_NAME = re.compile(r"heading ([1-9])", re.IGNORECASE)
16
+ _HEADING_ID = re.compile(r"Heading([1-9])")
17
+ _MAX_CHAIN = 64
18
+ _ON = ("1", "true", "on")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Style:
23
+ id: str
24
+ name: str
25
+ type: str
26
+ based_on: str | None
27
+ next: str | None
28
+ link: str | None
29
+ default: bool
30
+ custom: bool
31
+ quick: bool
32
+ ppr: dict[str, Any]
33
+ rpr: dict[str, Any]
34
+
35
+
36
+ class StyleTable:
37
+ """Every ``w:style`` of the document, in file order."""
38
+
39
+ def __init__(self, styles: list[Style], defaults_ppr: dict[str, Any] | None = None) -> None:
40
+ self.defaults_ppr: dict[str, Any] = defaults_ppr or {}
41
+ self.styles = tuple(styles)
42
+ self._by_id = {s.id: s for s in styles}
43
+ self._by_name = {s.name.lower(): s for s in reversed(styles)}
44
+ self.default_paragraph: str | None = next(
45
+ (s.id for s in styles if s.type == "paragraph" and s.default), None
46
+ )
47
+
48
+ def __len__(self) -> int:
49
+ return len(self.styles)
50
+
51
+ def get(self, style_id: str) -> Style | None:
52
+ return self._by_id.get(style_id)
53
+
54
+ def find(self, key: str) -> Style | None:
55
+ """A style by id, else by name (case-insensitive)."""
56
+ return self._by_id.get(key) or self._by_name.get(key.lower())
57
+
58
+ def chain(self, style_id: str) -> list[Style]:
59
+ """``style_id`` followed by its ``basedOn`` ancestors; a cycle ends the chain."""
60
+ out: list[Style] = []
61
+ seen: set[str] = set()
62
+ current = self._by_id.get(style_id)
63
+ while current is not None and current.id not in seen and len(out) < _MAX_CHAIN:
64
+ out.append(current)
65
+ seen.add(current.id)
66
+ current = self._by_id.get(current.based_on) if current.based_on else None
67
+ return out
68
+
69
+ def effective(self, style_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
70
+ """Paragraph and run formatting after inheritance (nearest style wins per key)."""
71
+ ppr: dict[str, Any] = {}
72
+ rpr: dict[str, Any] = {}
73
+ for style in reversed(self.chain(style_id)):
74
+ ppr.update(style.ppr)
75
+ rpr.update(style.rpr)
76
+ return ppr, rpr
77
+
78
+ def heading_level(self, style_id: str | None) -> int | None:
79
+ """1-9 for a heading style, else None.
80
+
81
+ The first outline level found walking up ``basedOn`` decides (9 means body
82
+ text, so None); without any, a style named ``heading N`` or with id
83
+ ``HeadingN`` is level N.
84
+ """
85
+ if style_id is None:
86
+ return None
87
+ chain = self.chain(style_id)
88
+ for style in chain:
89
+ level = style.ppr.get("outline_level")
90
+ if level is not None:
91
+ return level + 1 if 0 <= level <= 8 else None
92
+ for style in chain:
93
+ m = _HEADING_NAME.fullmatch(style.name) or _HEADING_ID.fullmatch(style.id)
94
+ if m:
95
+ return int(m.group(1))
96
+ return None
97
+
98
+
99
+ def _flag(elem: ET.Element, tag: str) -> bool:
100
+ child = elem.find(W + tag)
101
+ return child is not None and (child.get(W + "val") or "1").lower() not in ("0", "false", "off")
102
+
103
+
104
+ def load_styles(pkg: Package) -> StyleTable:
105
+ name = related_part(pkg, OFFICE_REL + "styles", STYLES_PART)
106
+ if name is None:
107
+ return StyleTable([])
108
+ root = parse_part(pkg, name)
109
+ out: list[Style] = []
110
+ for elem in root.findall(W + "style"):
111
+ style_id = elem.get(W + "styleId")
112
+ if not style_id:
113
+ continue
114
+ label = elem.find(W + "name")
115
+ based = elem.find(W + "basedOn")
116
+ nxt = elem.find(W + "next")
117
+ link = elem.find(W + "link")
118
+ out.append(
119
+ Style(
120
+ id=style_id,
121
+ name=(label.get(W + "val") if label is not None else None) or style_id,
122
+ type=elem.get(W + "type") or "paragraph",
123
+ based_on=based.get(W + "val") if based is not None else None,
124
+ next=nxt.get(W + "val") if nxt is not None else None,
125
+ link=link.get(W + "val") if link is not None else None,
126
+ default=(elem.get(W + "default") or "0").lower() in _ON,
127
+ custom=(elem.get(W + "customStyle") or "0").lower() in _ON,
128
+ quick=_flag(elem, "qFormat"),
129
+ ppr=para_format(elem.find(W + "pPr")),
130
+ rpr=run_format(elem.find(W + "rPr")),
131
+ )
132
+ )
133
+ defaults = root.find(f"{W}docDefaults/{W}pPrDefault/{W}pPr")
134
+ return StyleTable(out, para_format(defaults))
@@ -0,0 +1 @@
1
+ """Office Open XML package handling (stdlib only)."""