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