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,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)."""
|