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,445 @@
|
|
|
1
|
+
"""Segmentation of visible paragraph text: sentences, dialogue lines, scene breaks, chapters.
|
|
2
|
+
|
|
3
|
+
Everything here is a pure function of text. Offsets are code points of the visible
|
|
4
|
+
text, the end exclusive, exactly as in :mod:`doc_control.model.index`, and are the
|
|
5
|
+
offsets the ``p<n>#<fp>.s<k>`` sentence address resolves to.
|
|
6
|
+
|
|
7
|
+
Sentences
|
|
8
|
+
:func:`split_sentences` ends a sentence at a run of ``. ! ? …`` (plus the closing
|
|
9
|
+
quotes and brackets that follow it) when whitespace or the end of the text comes
|
|
10
|
+
next, and the next token can start a sentence: an upper-case letter, a digit or an
|
|
11
|
+
opening mark (``¿ ¡ « “ " ( [``). It does not end after:
|
|
12
|
+
|
|
13
|
+
* a known abbreviation (``Sr.``, ``Dra.``, ``pág.``, ``Mr.``…), a dotted
|
|
14
|
+
abbreviation (``EE.UU.``, ``a.m.``) or an initial (``J. R. Tolkien``);
|
|
15
|
+
* a terminator followed by a lower-case letter, including an ellipsis
|
|
16
|
+
(``Pues... no sé``) and a ``?``/``!`` before a closing quote or a raya
|
|
17
|
+
narrator interjection (``—¿Vienes? —preguntó``);
|
|
18
|
+
* a number (``3.14``): no whitespace follows the dot.
|
|
19
|
+
|
|
20
|
+
``etc.`` and ``no.`` end a sentence only before a capital. A line break (``\\n``)
|
|
21
|
+
always ends one. ``language`` selects the abbreviation list: ``"es"`` (the
|
|
22
|
+
default) or ``"en"``/``"generic"``; any other value is ``SCHEMA_INVALID``.
|
|
23
|
+
|
|
24
|
+
Dialogue
|
|
25
|
+
:func:`dialogue_lines` finds the lines that open with a raya (``—``) and splits
|
|
26
|
+
each into speech and narrator parts: ``—Hola —dijo Ana—. Vamos.`` has speech
|
|
27
|
+
``Hola``, narration ``dijo Ana`` and speech ``. Vamos.``.
|
|
28
|
+
|
|
29
|
+
Scene breaks and chapters
|
|
30
|
+
:func:`is_scene_break` recognises ornament paragraphs (``* * *``, ``---``, ``###``).
|
|
31
|
+
:func:`parse_chapter_heading` recognises ``Capítulo 3``, ``CHAPTER IV. Title``,
|
|
32
|
+
``Prólogo`` and bare numerals; :func:`segment_document` applies both to a list of
|
|
33
|
+
paragraph texts. Stable ids, signals and overrides belong to structure detection.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
from collections.abc import Sequence
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import Literal
|
|
42
|
+
|
|
43
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
44
|
+
|
|
45
|
+
DEFAULT_LANGUAGE = "es"
|
|
46
|
+
LANGUAGES = ("es", "en", "generic")
|
|
47
|
+
|
|
48
|
+
_TERMINATORS = ".!?\N{HORIZONTAL ELLIPSIS}"
|
|
49
|
+
_CLOSERS = "\"'»\u201d\u2019)]}\u203a"
|
|
50
|
+
_RAYAS = "—―"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _words(text: str) -> frozenset[str]:
|
|
54
|
+
return frozenset(text.split())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_COMMON_ABBREVIATIONS = _words(
|
|
58
|
+
"vs dr dra prof sr sra srta ud uds vd vds st jr mr mrs ms mt fig vol ed no etc cf pp tel apdo"
|
|
59
|
+
)
|
|
60
|
+
_SPANISH_ABBREVIATIONS = _words(
|
|
61
|
+
"dña doña dn excmo excma ilmo ilma "
|
|
62
|
+
"lic ing arq sres sras av avda pág págs art arts cap caps núm nº dpto depto aprox "
|
|
63
|
+
"máx mín cía cia ej admón gral cnel cmdr mons fr izq dcha esq pl pza ctra"
|
|
64
|
+
)
|
|
65
|
+
_GENERIC_ABBREVIATIONS = _words(
|
|
66
|
+
"inc ltd co corp gen col capt lt sgt rev hon esq ave blvd rd approx dept est "
|
|
67
|
+
"ch sec pt pg a.m. p.m."
|
|
68
|
+
)
|
|
69
|
+
_ABBREVIATIONS = {
|
|
70
|
+
"es": _COMMON_ABBREVIATIONS | _SPANISH_ABBREVIATIONS,
|
|
71
|
+
"generic": _COMMON_ABBREVIATIONS | _GENERIC_ABBREVIATIONS,
|
|
72
|
+
}
|
|
73
|
+
# Abbreviations that may also close a sentence: they end one only before a capital.
|
|
74
|
+
_SOFT_ABBREVIATIONS = _words("etc no co est inc ltd corp ave blvd rd approx dept a.m. p.m.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Span:
|
|
79
|
+
"""Characters ``[start, end)`` of a paragraph's visible text."""
|
|
80
|
+
|
|
81
|
+
start: int
|
|
82
|
+
end: int
|
|
83
|
+
|
|
84
|
+
def text(self, source: str) -> str:
|
|
85
|
+
return source[self.start : self.end]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class DialoguePart:
|
|
90
|
+
"""A stretch of a dialogue line: the characters' ``speech`` or the ``narration``."""
|
|
91
|
+
|
|
92
|
+
kind: Literal["speech", "narration"]
|
|
93
|
+
start: int
|
|
94
|
+
end: int
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class DialogueLine:
|
|
99
|
+
"""A line opening with a raya, from the raya to the end of the line."""
|
|
100
|
+
|
|
101
|
+
start: int
|
|
102
|
+
end: int
|
|
103
|
+
parts: tuple[DialoguePart, ...]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class ChapterHeading:
|
|
108
|
+
"""A recognised chapter heading.
|
|
109
|
+
|
|
110
|
+
``kind`` is ``chapter``, ``part``, ``prologue``, ``epilogue``, ``introduction`` or
|
|
111
|
+
``numeral`` (a bare number); ``number`` is ``None`` when the heading has none.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
kind: str
|
|
115
|
+
number: int | None
|
|
116
|
+
title: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Chapter:
|
|
121
|
+
"""Paragraphs ``first``..``last`` (1-based, inclusive) under one heading."""
|
|
122
|
+
|
|
123
|
+
first: int
|
|
124
|
+
last: int
|
|
125
|
+
heading: ChapterHeading
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Segmentation:
|
|
130
|
+
"""Paragraph-level segmentation of a document; paragraph numbers are 1-based."""
|
|
131
|
+
|
|
132
|
+
sentences: tuple[tuple[Span, ...], ...] # per paragraph, in order
|
|
133
|
+
dialogue: tuple[tuple[int, DialogueLine], ...]
|
|
134
|
+
scene_breaks: tuple[int, ...]
|
|
135
|
+
chapters: tuple[Chapter, ...]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------- sentences
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def split_sentences(text: str, language: str = DEFAULT_LANGUAGE) -> list[Span]:
|
|
142
|
+
"""The sentences of one paragraph's ``text``, in order, trimmed of whitespace."""
|
|
143
|
+
abbreviations = _abbreviations(language)
|
|
144
|
+
spans: list[Span] = []
|
|
145
|
+
base = 0
|
|
146
|
+
for line in text.split("\n"):
|
|
147
|
+
spans.extend(_split_line(line, base, abbreviations))
|
|
148
|
+
base += len(line) + 1
|
|
149
|
+
return spans
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _abbreviations(language: str) -> frozenset[str]:
|
|
153
|
+
if language not in LANGUAGES:
|
|
154
|
+
raise DocControlError(
|
|
155
|
+
ErrorCode.SCHEMA_INVALID,
|
|
156
|
+
f"unknown language {language!r}; expected one of {', '.join(LANGUAGES)}",
|
|
157
|
+
language=language,
|
|
158
|
+
)
|
|
159
|
+
return _ABBREVIATIONS["es" if language == "es" else "generic"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def sentence_span(text: str, k: int, language: str = DEFAULT_LANGUAGE) -> Span | None:
|
|
163
|
+
"""Sentence ``k`` (1-based) of ``text``, or ``None`` when there are fewer."""
|
|
164
|
+
spans = split_sentences(text, language)
|
|
165
|
+
return spans[k - 1] if 1 <= k <= len(spans) else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_SPACED_ELLIPSIS = re.compile(r"\.(?: \.){2,}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _split_line(line: str, base: int, abbreviations: frozenset[str]) -> list[Span]:
|
|
172
|
+
spans: list[Span] = []
|
|
173
|
+
n = len(line)
|
|
174
|
+
start = _skip_space(line, 0)
|
|
175
|
+
i = start
|
|
176
|
+
while i < n:
|
|
177
|
+
if line[i] not in _TERMINATORS:
|
|
178
|
+
i += 1
|
|
179
|
+
continue
|
|
180
|
+
run_start = i
|
|
181
|
+
spaced = _SPACED_ELLIPSIS.match(line, i)
|
|
182
|
+
if spaced is not None:
|
|
183
|
+
i = spaced.end()
|
|
184
|
+
while i < n and line[i] in _TERMINATORS:
|
|
185
|
+
i += 1
|
|
186
|
+
while i < n and line[i] in _CLOSERS:
|
|
187
|
+
i += 1
|
|
188
|
+
if i < n and not line[i].isspace():
|
|
189
|
+
continue # 3.14, www.x.com, «Vamos.», dijo
|
|
190
|
+
nxt = _skip_space(line, i)
|
|
191
|
+
if nxt < n and not _ends_sentence(line, start, run_start, nxt, abbreviations):
|
|
192
|
+
continue
|
|
193
|
+
spans.append(Span(base + start, base + i))
|
|
194
|
+
start = i = nxt
|
|
195
|
+
if start < n and not line[start:].isspace():
|
|
196
|
+
spans.append(Span(base + start, base + len(line.rstrip())))
|
|
197
|
+
return spans
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _skip_space(text: str, i: int) -> int:
|
|
201
|
+
while i < len(text) and text[i].isspace():
|
|
202
|
+
i += 1
|
|
203
|
+
return i
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _ends_sentence(
|
|
207
|
+
line: str, sentence_start: int, run_start: int, nxt: int, abbreviations: frozenset[str]
|
|
208
|
+
) -> bool:
|
|
209
|
+
"""Whether the terminator run at ``run_start`` ends the sentence, given what follows."""
|
|
210
|
+
follower = _classify_follower(line, nxt)
|
|
211
|
+
if (
|
|
212
|
+
line[run_start] == "."
|
|
213
|
+
and not line.startswith("..", run_start)
|
|
214
|
+
and _SPACED_ELLIPSIS.match(line, run_start) is None
|
|
215
|
+
):
|
|
216
|
+
token = _word_before(line, sentence_start, run_start)
|
|
217
|
+
folded = token.lower()
|
|
218
|
+
start = run_start - len(token)
|
|
219
|
+
if start > 0 and line[start - 1].isdigit():
|
|
220
|
+
return follower == "start" # 1st. 2nd. (an ordinal, not an abbreviation)
|
|
221
|
+
if folded == "st" and token == "St" and _capitalised_before(line, sentence_start, start):
|
|
222
|
+
return follower == "start" # Elm St. (a street), not St. John
|
|
223
|
+
if folded in abbreviations or ("." in token and len(token) <= 8): # EE.UU. S.A.
|
|
224
|
+
soft = folded in _SOFT_ABBREVIATIONS or ("." in token and folded not in abbreviations)
|
|
225
|
+
return soft and follower == "start" and not line[nxt].isdigit()
|
|
226
|
+
if len(token) == 1 and token.isupper() and follower == "start":
|
|
227
|
+
return False # initial: J. R. Tolkien
|
|
228
|
+
return follower == "start"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _capitalised_before(line: str, floor: int, end: int) -> bool:
|
|
232
|
+
"""Whether the word ending just before ``end`` starts with a capital letter."""
|
|
233
|
+
word = _word_before(line, floor, len(line[:end].rstrip()))
|
|
234
|
+
return word[:1].isupper()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _word_before(line: str, floor: int, end: int) -> str:
|
|
238
|
+
i = end
|
|
239
|
+
while i > floor and (line[i - 1].isalpha() or line[i - 1] == "."):
|
|
240
|
+
i -= 1
|
|
241
|
+
return line[i:end]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _classify_follower(line: str, nxt: int) -> Literal["start", "continue"]:
|
|
245
|
+
"""Whether the text at ``nxt`` can open a new sentence or continues the old one."""
|
|
246
|
+
ch = line[nxt]
|
|
247
|
+
if ch in _RAYAS:
|
|
248
|
+
after = _skip_space(line, nxt + 1)
|
|
249
|
+
if after >= len(line):
|
|
250
|
+
return "start"
|
|
251
|
+
ch = line[after]
|
|
252
|
+
return "continue" if ch.islower() else "start"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------- dialogue
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def dialogue_lines(text: str) -> list[DialogueLine]:
|
|
259
|
+
"""The dialogue lines of ``text``: each physical line opening with a raya."""
|
|
260
|
+
lines: list[DialogueLine] = []
|
|
261
|
+
base = 0
|
|
262
|
+
for line in text.split("\n"):
|
|
263
|
+
first = _skip_space(line, 0)
|
|
264
|
+
if first < len(line) and line[first] in _RAYAS:
|
|
265
|
+
lines.append(_dialogue_line(line, base, first))
|
|
266
|
+
base += len(line) + 1
|
|
267
|
+
return lines
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _dialogue_line(line: str, base: int, first: int) -> DialogueLine:
|
|
271
|
+
parts: list[DialoguePart] = []
|
|
272
|
+
|
|
273
|
+
def add(kind: Literal["speech", "narration"], lo: int, hi: int) -> None:
|
|
274
|
+
while lo < hi and line[lo].isspace():
|
|
275
|
+
lo += 1
|
|
276
|
+
while hi > lo and line[hi - 1].isspace():
|
|
277
|
+
hi -= 1
|
|
278
|
+
if lo < hi:
|
|
279
|
+
parts.append(DialoguePart(kind, base + lo, base + hi))
|
|
280
|
+
|
|
281
|
+
kind: Literal["speech", "narration"] = "speech"
|
|
282
|
+
pos = first + 1
|
|
283
|
+
for i in range(first + 1, len(line)):
|
|
284
|
+
if line[i] not in _RAYAS:
|
|
285
|
+
continue
|
|
286
|
+
add(kind, pos, i)
|
|
287
|
+
if kind == "narration":
|
|
288
|
+
kind = "speech"
|
|
289
|
+
elif not _new_turn(line, pos, i):
|
|
290
|
+
kind = "narration"
|
|
291
|
+
pos = i + 1
|
|
292
|
+
add(kind, pos, len(line))
|
|
293
|
+
return DialogueLine(base + first, base + len(line.rstrip()), tuple(parts))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _new_turn(line: str, lo: int, raya: int) -> bool:
|
|
297
|
+
"""Whether a raya in speech starts another turn: ``—Vamos. —Ya voy``."""
|
|
298
|
+
before = line[lo:raya].rstrip()
|
|
299
|
+
if not before or before[-1] not in _TERMINATORS:
|
|
300
|
+
return False
|
|
301
|
+
after = _skip_space(line, raya + 1)
|
|
302
|
+
return after < len(line) and not line[after].islower()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------------ scene breaks
|
|
306
|
+
|
|
307
|
+
_BREAK_CHARS = frozenset("*#~-_=•·+◊◆◇✦✧\N{EN DASH}\N{EM DASH}")
|
|
308
|
+
_ORNAMENTS = frozenset("⁂❦❧※§")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def is_scene_break(text: str) -> bool:
|
|
312
|
+
"""Whether a paragraph is only a scene-break ornament: ``* * *``, ``---``, ``⁂``."""
|
|
313
|
+
marks = "".join(text.split())
|
|
314
|
+
if not marks or len(set(marks)) != 1:
|
|
315
|
+
return False
|
|
316
|
+
if len(marks) == 1 and marks in "*#":
|
|
317
|
+
return True
|
|
318
|
+
if marks[0] in _ORNAMENTS:
|
|
319
|
+
return True
|
|
320
|
+
return len(marks) >= 3 and marks[0] in _BREAK_CHARS
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------- chapters
|
|
324
|
+
|
|
325
|
+
_WORDS = (
|
|
326
|
+
"uno dos tres cuatro cinco seis siete ocho nueve diez once doce trece catorce quince "
|
|
327
|
+
"dieciséis diecisiete dieciocho diecinueve veinte",
|
|
328
|
+
"primero segundo tercero cuarto quinto sexto séptimo octavo noveno décimo",
|
|
329
|
+
"primera segunda tercera cuarta quinta sexta séptima octava novena décima",
|
|
330
|
+
"one two three four five six seven eight nine ten eleven twelve thirteen fourteen "
|
|
331
|
+
"fifteen sixteen seventeen eighteen nineteen twenty",
|
|
332
|
+
"first second third fourth fifth sixth seventh eighth ninth tenth",
|
|
333
|
+
)
|
|
334
|
+
_NUMBER_WORDS: dict[str, int] = {
|
|
335
|
+
word: i for group in _WORDS for i, word in enumerate(group.split(), 1)
|
|
336
|
+
}
|
|
337
|
+
_NUMBER_WORDS["una"] = 1
|
|
338
|
+
_ROMAN = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
|
|
339
|
+
_ROMAN_FORMS = (
|
|
340
|
+
("M", 1000), ("CM", 900), ("D", 500), ("CD", 400), ("C", 100), ("XC", 90),
|
|
341
|
+
("L", 50), ("XL", 40), ("X", 10), ("IX", 9), ("V", 5), ("IV", 4), ("I", 1),
|
|
342
|
+
) # fmt: skip
|
|
343
|
+
_HEADING = re.compile(
|
|
344
|
+
r"(?P<word>cap[ií]tulo|cap\.|chapter|chap\.|parte|part|libro|book)\s*"
|
|
345
|
+
r"(?P<num>\d{1,4}|[ivxlcdm]{1,8}|[^\W\d_]{3,12})(?![^\W\d_])\s*[.:)\-\u2013\u2014]?\s*(?P<title>.*)",
|
|
346
|
+
re.IGNORECASE,
|
|
347
|
+
)
|
|
348
|
+
_REVERSED = re.compile(
|
|
349
|
+
r"(?P<num>[^\W\d_]{4,8})\s+(?P<word>cap[ií]tulo|chapter|parte|part|libro|book)"
|
|
350
|
+
r"\s*(?:[.:\-\u2013\u2014]\s*(?P<title>.*))?",
|
|
351
|
+
re.IGNORECASE,
|
|
352
|
+
)
|
|
353
|
+
_NAMED = re.compile(
|
|
354
|
+
r"(?P<word>pr[óo]logo|ep[íi]logo|prologue|epilogue|prefacio|preface|"
|
|
355
|
+
r"introducci[óo]n|introduction)\s*(?:[.:\-\u2013\u2014]\s*(?P<title>.*))?",
|
|
356
|
+
re.IGNORECASE,
|
|
357
|
+
)
|
|
358
|
+
_BARE = re.compile(r"(?P<num>\d{1,3}|[IVXLCDM]{1,8})[.)]?")
|
|
359
|
+
_KIND_BY_WORD = {
|
|
360
|
+
"cap": "chapter", "cap.": "chapter", "chap": "chapter", "chap.": "chapter",
|
|
361
|
+
"capítulo": "chapter", "capitulo": "chapter", "chapter": "chapter",
|
|
362
|
+
"parte": "part", "part": "part", "libro": "part", "book": "part",
|
|
363
|
+
"prólogo": "prologue", "prologo": "prologue", "prologue": "prologue",
|
|
364
|
+
"prefacio": "prologue", "preface": "prologue",
|
|
365
|
+
"epílogo": "epilogue", "epilogo": "epilogue", "epilogue": "epilogue",
|
|
366
|
+
"introducción": "introduction", "introduccion": "introduction",
|
|
367
|
+
"introduction": "introduction",
|
|
368
|
+
} # fmt: skip
|
|
369
|
+
_MAX_HEADING = 100
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _to_roman(value: int) -> str:
|
|
373
|
+
out = ""
|
|
374
|
+
for symbol, size in _ROMAN_FORMS:
|
|
375
|
+
while value >= size:
|
|
376
|
+
out += symbol
|
|
377
|
+
value -= size
|
|
378
|
+
return out
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _roman(text: str) -> int | None:
|
|
382
|
+
"""The value of a well-formed Roman numeral, else ``None``."""
|
|
383
|
+
upper = text.upper()
|
|
384
|
+
if not upper or any(c not in _ROMAN for c in upper):
|
|
385
|
+
return None
|
|
386
|
+
total = 0
|
|
387
|
+
for i, c in enumerate(upper):
|
|
388
|
+
value = _ROMAN[c]
|
|
389
|
+
total += -value if i + 1 < len(upper) and _ROMAN[upper[i + 1]] > value else value
|
|
390
|
+
return total if _to_roman(total) == upper else None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _number(text: str) -> int | None:
|
|
394
|
+
if text.isdigit():
|
|
395
|
+
return int(text) or None
|
|
396
|
+
return _NUMBER_WORDS.get(text.lower()) or _roman(text)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def parse_chapter_heading(text: str) -> ChapterHeading | None:
|
|
400
|
+
"""The heading a paragraph's ``text`` is, or ``None`` if it is not one."""
|
|
401
|
+
stripped = " ".join(text.split())
|
|
402
|
+
if not stripped or len(stripped) > _MAX_HEADING:
|
|
403
|
+
return None
|
|
404
|
+
m = _HEADING.fullmatch(stripped)
|
|
405
|
+
if m is not None:
|
|
406
|
+
number = _number(m["num"])
|
|
407
|
+
if number is None:
|
|
408
|
+
return None
|
|
409
|
+
return ChapterHeading(_KIND_BY_WORD[m["word"].lower()], number, m["title"].strip())
|
|
410
|
+
m = _REVERSED.fullmatch(stripped)
|
|
411
|
+
if m is not None:
|
|
412
|
+
number = _number(m["num"])
|
|
413
|
+
if number is None:
|
|
414
|
+
return None
|
|
415
|
+
return ChapterHeading(_KIND_BY_WORD[m["word"].lower()], number, (m["title"] or "").strip())
|
|
416
|
+
m = _NAMED.fullmatch(stripped)
|
|
417
|
+
if m is not None:
|
|
418
|
+
return ChapterHeading(_KIND_BY_WORD[m["word"].lower()], None, (m["title"] or "").strip())
|
|
419
|
+
m = _BARE.fullmatch(stripped)
|
|
420
|
+
if m is not None and m["num"] not in ("L", "C", "D", "M"): # a lone letter is no numeral
|
|
421
|
+
number = _number(m["num"])
|
|
422
|
+
if number is not None:
|
|
423
|
+
return ChapterHeading("numeral", number, "")
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def number_word(text: str) -> int | None:
|
|
428
|
+
"""The value of a spelled-out number alone (``Uno``, ``Tercera``, ``Twelve``), else ``None``."""
|
|
429
|
+
return _NUMBER_WORDS.get(" ".join(text.split()).rstrip(".").lower())
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def segment_document(texts: Sequence[str], language: str = DEFAULT_LANGUAGE) -> Segmentation:
|
|
433
|
+
"""Segment a document given each main-story paragraph's visible text, in order."""
|
|
434
|
+
_abbreviations(language)
|
|
435
|
+
sentences = tuple(tuple(split_sentences(t, language)) for t in texts)
|
|
436
|
+
dialogue = tuple((n, line) for n, t in enumerate(texts, 1) for line in dialogue_lines(t))
|
|
437
|
+
breaks = tuple(n for n, t in enumerate(texts, 1) if is_scene_break(t))
|
|
438
|
+
headings = [
|
|
439
|
+
(n, h) for n, t in enumerate(texts, 1) if (h := parse_chapter_heading(t)) is not None
|
|
440
|
+
]
|
|
441
|
+
chapters = tuple(
|
|
442
|
+
Chapter(n, headings[i + 1][0] - 1 if i + 1 < len(headings) else len(texts), h)
|
|
443
|
+
for i, (n, h) in enumerate(headings)
|
|
444
|
+
)
|
|
445
|
+
return Segmentation(sentences, dialogue, breaks, chapters)
|