readalign 1.0.0__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.
readalign/epub.py ADDED
@@ -0,0 +1,671 @@
1
+ """Read an EPUB, find its readable text, and give every sentence an addressable fragment id.
2
+
3
+ Nothing here writes to the user's file: the package is loaded into memory, content documents are
4
+ modified in that copy, and :mod:`readalign.package` writes the result somewhere new.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import posixpath
10
+ import re
11
+ import zipfile
12
+ from dataclasses import dataclass, field
13
+
14
+ from lxml import etree
15
+
16
+ from .errors import DRMError, InputError
17
+ from .members import ArchiveMember, Member, open_archive
18
+ from .sentences import split_sentences
19
+
20
+ XHTML_NS = "http://www.w3.org/1999/xhtml"
21
+ OPF_NS = "http://www.idpf.org/2007/opf"
22
+ DC_NS = "http://purl.org/dc/elements/1.1/"
23
+ CONTAINER_NS = "urn:oasis:names:tc:opendocument:xmlns:container"
24
+ ENC_NS = "http://www.w3.org/2001/04/xmlenc#"
25
+ EPUB_NS = "http://www.idpf.org/2007/ops"
26
+
27
+ #: Encryption algorithms that mean "obfuscated font", not "DRM".
28
+ _FONT_OBFUSCATION = {
29
+ "http://www.idpf.org/2008/embedding",
30
+ "http://ns.adobe.com/pdf/enc#RC4SHA1",
31
+ }
32
+
33
+ _BLOCK_TAGS = {
34
+ "address", "article", "aside", "blockquote", "body", "caption", "details", "dialog", "div",
35
+ "dl", "dd", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3",
36
+ "h4", "h5", "h6", "header", "hgroup", "li", "main", "nav", "ol", "p", "section", "table",
37
+ "tbody", "td", "tfoot", "th", "thead", "tr", "ul",
38
+ }
39
+ _SKIP_TAGS = {"script", "style", "head", "title", "audio", "video", "iframe", "template"}
40
+ _ATOMIC_TAGS = {"svg", "math"}
41
+
42
+ #: epub:type values a reading system may let the user skip; copied onto the matching SMIL nodes.
43
+ SKIPPABLE_TYPES = {
44
+ "footnote", "endnote", "note", "rearnote", "pagebreak", "noteref", "annotation", "sidebar",
45
+ }
46
+
47
+ #: XHTML elements a reading system may let the user escape out of, and the epub:type naming the
48
+ #: structure. The overlay mirrors these as nested seq elements, which is what escapability means.
49
+ ESCAPABLE_TAGS = {
50
+ "table": "table",
51
+ "tr": "table-row",
52
+ "td": "table-cell",
53
+ "th": "table-cell",
54
+ "ol": "list",
55
+ "ul": "list",
56
+ "li": "list-item",
57
+ "figure": "figure",
58
+ }
59
+
60
+ #: epub:type values that mark a reference, not narration. Their text is left out of the
61
+ #: sentence stream so a footnote marker cannot glue two sentences together.
62
+ _MARKER_TYPES = {"noteref", "pagebreak"}
63
+
64
+ _ID_SAFE = re.compile(r"[^A-Za-z0-9._-]")
65
+
66
+
67
+ def _is_marker(element: etree._Element) -> bool:
68
+ types = (element.get(f"{{{EPUB_NS}}}type") or "").split()
69
+ return any(value in _MARKER_TYPES for value in types)
70
+
71
+
72
+ def local_name(tag: object) -> str:
73
+ """The tag name without its namespace, or "" for comments and processing instructions."""
74
+ if not isinstance(tag, str):
75
+ return ""
76
+ return tag.rsplit("}", 1)[-1]
77
+
78
+
79
+ def _namespace_of(element: etree._Element) -> str:
80
+ tag = element.tag
81
+ if isinstance(tag, str) and tag.startswith("{"):
82
+ return tag[: tag.index("}") + 1]
83
+ return ""
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Structure:
88
+ """One ancestor the overlay has to mirror, as a SMIL ``seq`` carrying these two values."""
89
+
90
+ epub_type: str
91
+ container_id: str
92
+
93
+
94
+ @dataclass
95
+ class Sentence:
96
+ """One narratable unit of text and the fragment id a SMIL ``text`` element points at."""
97
+
98
+ doc_index: int
99
+ index: int
100
+ fragment_id: str
101
+ text: str
102
+ words: list[str]
103
+ #: Enclosing structures, outermost first, whether skippable or merely escapable.
104
+ structure: tuple[Structure, ...] = ()
105
+
106
+
107
+ @dataclass
108
+ class ContentDoc:
109
+ manifest_id: str
110
+ zip_path: str
111
+ href: str
112
+ tree: etree._ElementTree
113
+ sentences: list[Sentence] = field(default_factory=list)
114
+ recovered: bool = False
115
+ reencoded_from: str | None = None
116
+
117
+ def serialize(self) -> bytes:
118
+ return etree.tostring(self.tree, xml_declaration=True, encoding="utf-8")
119
+
120
+
121
+ @dataclass
122
+ class ManifestItem:
123
+ id: str
124
+ href: str
125
+ media_type: str
126
+ properties: str | None
127
+ element: etree._Element
128
+
129
+
130
+ @dataclass
131
+ class EpubPackage:
132
+ source: str
133
+ #: Every member of the input archive. Parsed ones hold bytes; the rest, which includes any
134
+ #: audio a previous run embedded, are references copied through at write time.
135
+ files: Member
136
+ order: list[str]
137
+ opf_path: str
138
+ opf_tree: etree._ElementTree
139
+ version: str
140
+ manifest: dict[str, ManifestItem]
141
+ spine_ids: list[str]
142
+ docs: list[ContentDoc]
143
+
144
+ @property
145
+ def opf_dir(self) -> str:
146
+ return posixpath.dirname(self.opf_path)
147
+
148
+ def resolve(self, href: str) -> str:
149
+ """Turn an href relative to the package document into a path inside the archive."""
150
+ joined = posixpath.join(self.opf_dir, href) if self.opf_dir else href
151
+ return posixpath.normpath(joined)
152
+
153
+ def title(self) -> str | None:
154
+ for element in self.opf_tree.getroot().iter(f"{{{DC_NS}}}title"):
155
+ if element.text and element.text.strip():
156
+ return element.text.strip()
157
+ return None
158
+
159
+
160
+ def _parse_xml(data: bytes, what: str) -> etree._ElementTree:
161
+ parser = etree.XMLParser(resolve_entities=False, huge_tree=True)
162
+ try:
163
+ return etree.ElementTree(etree.fromstring(data, parser))
164
+ except etree.XMLSyntaxError as exc:
165
+ raise InputError(f"{what} is not well-formed XML: {exc}") from exc
166
+
167
+
168
+ def _decode_candidates(data: bytes) -> list[str]:
169
+ match = re.search(rb'encoding=["\']([\w-]+)["\']', data[:200])
170
+ declared = match[1].decode("ascii", "ignore") if match else None
171
+ ordered = [declared, "utf-8", "utf-8-sig", "cp1252", "latin-1"]
172
+ seen: list[str] = []
173
+ for encoding in ordered:
174
+ if encoding and encoding.lower() not in {item.lower() for item in seen}:
175
+ seen.append(encoding)
176
+ return seen
177
+
178
+
179
+ def _strip_declaration(text: str) -> bytes:
180
+ return re.sub(r"^?<\?xml[^>]*\?>", "", text, count=1).lstrip().encode("utf-8")
181
+
182
+
183
+ def parse_content_document(data: bytes, what: str) -> tuple[etree._ElementTree, bool, str | None]:
184
+ """Parse an XHTML content document, surviving a mislabelled or broken encoding.
185
+
186
+ Returns the tree, whether a recovering parse was needed, and the encoding that finally
187
+ worked when it was not the declared one.
188
+ """
189
+ parser = etree.XMLParser(resolve_entities=False, huge_tree=True)
190
+ try:
191
+ return etree.ElementTree(etree.fromstring(data, parser)), False, None
192
+ except etree.XMLSyntaxError:
193
+ pass
194
+ for encoding in _decode_candidates(data):
195
+ try:
196
+ text = data.decode(encoding)
197
+ except (UnicodeDecodeError, LookupError):
198
+ continue
199
+ try:
200
+ tree = etree.ElementTree(etree.fromstring(_strip_declaration(text), parser))
201
+ except etree.XMLSyntaxError:
202
+ continue
203
+ return tree, False, encoding
204
+ recovering = etree.XMLParser(resolve_entities=False, recover=True, huge_tree=True)
205
+ for encoding in _decode_candidates(data):
206
+ try:
207
+ text = data.decode(encoding, errors="replace")
208
+ except LookupError:
209
+ continue
210
+ root = etree.fromstring(_strip_declaration(text), recovering)
211
+ if root is not None:
212
+ return etree.ElementTree(root), True, encoding
213
+ raise InputError(f"{what} could not be parsed as XHTML in any known encoding")
214
+
215
+
216
+ def _check_drm(files: dict[str, bytes]) -> None:
217
+ if "META-INF/rights.xml" in files:
218
+ raise DRMError("readalign does not handle DRM-protected files")
219
+ encryption = files.get("META-INF/encryption.xml")
220
+ if encryption is None:
221
+ return
222
+ try:
223
+ root = etree.fromstring(encryption, etree.XMLParser(resolve_entities=False))
224
+ except etree.XMLSyntaxError:
225
+ raise DRMError("readalign does not handle DRM-protected files") from None
226
+ for method in root.iter(f"{{{ENC_NS}}}EncryptionMethod"):
227
+ if method.get("Algorithm") not in _FONT_OBFUSCATION:
228
+ raise DRMError("readalign does not handle DRM-protected files")
229
+
230
+
231
+ def load_epub(path: str) -> EpubPackage:
232
+ """Load an EPUB 3 package and annotate every XHTML document in its spine."""
233
+ if path.lower().endswith(".acsm"):
234
+ raise DRMError("readalign does not handle DRM-protected files")
235
+ with open_archive(path) as archive:
236
+ try:
237
+ return _load_open(path, archive)
238
+ except zipfile.BadZipFile as exc:
239
+ # A corrupt member survives the central directory and only fails on read.
240
+ raise InputError(f"{path} is not a readable EPUB: {exc}") from None
241
+
242
+
243
+ def _load_open(path: str, archive: zipfile.ZipFile) -> EpubPackage:
244
+ order = archive.namelist()
245
+ files: Member = {
246
+ name: ArchiveMember(path, name) for name in order if not name.endswith("/")
247
+ }
248
+ for name in files:
249
+ if name.startswith("META-INF/"):
250
+ files[name] = archive.read(name)
251
+
252
+ _check_drm(files)
253
+
254
+ container = files.get("META-INF/container.xml")
255
+ if container is None:
256
+ raise InputError(f"not an EPUB: {path} has no META-INF/container.xml")
257
+ rootfiles = list(_parse_xml(container, "META-INF/container.xml")
258
+ .getroot().iter(f"{{{CONTAINER_NS}}}rootfile"))
259
+ opf_path = next(
260
+ (
261
+ element.get("full-path")
262
+ for element in rootfiles
263
+ if element.get("media-type") == "application/oebps-package+xml"
264
+ or (element.get("full-path") or "").endswith(".opf")
265
+ ),
266
+ None,
267
+ )
268
+ if not opf_path:
269
+ raise InputError("META-INF/container.xml names no package document")
270
+ if opf_path not in files:
271
+ raise InputError(f"package document {opf_path} is missing from the archive")
272
+ files[opf_path] = archive.read(opf_path)
273
+
274
+ opf_tree = _parse_xml(files[opf_path], opf_path)
275
+ root = opf_tree.getroot()
276
+ version = root.get("version", "")
277
+ if not version.startswith("3"):
278
+ raise InputError(
279
+ f"{path} is an EPUB {version or '2'} package; media overlays need EPUB 3. "
280
+ "Convert it to EPUB 3 first (Calibre can do this)."
281
+ )
282
+
283
+ manifest: dict[str, ManifestItem] = {}
284
+ for element in root.iter(f"{{{OPF_NS}}}item"):
285
+ item_id = element.get("id")
286
+ href = element.get("href")
287
+ if item_id and href:
288
+ manifest[item_id] = ManifestItem(
289
+ id=item_id,
290
+ href=href,
291
+ media_type=element.get("media-type", ""),
292
+ properties=element.get("properties"),
293
+ element=element,
294
+ )
295
+ spine_ids = [
296
+ element.get("idref")
297
+ for element in root.iter(f"{{{OPF_NS}}}itemref")
298
+ if element.get("idref")
299
+ ]
300
+ if not spine_ids:
301
+ raise InputError(f"{path} has an empty spine")
302
+
303
+ package = EpubPackage(
304
+ source=path,
305
+ files=files,
306
+ order=order,
307
+ opf_path=opf_path,
308
+ opf_tree=opf_tree,
309
+ version=version,
310
+ manifest=manifest,
311
+ spine_ids=spine_ids,
312
+ docs=[],
313
+ )
314
+ _load_documents(package, archive)
315
+ if not package.docs:
316
+ raise InputError(f"{path} has no XHTML content documents in its spine")
317
+ return package
318
+
319
+
320
+ def _load_documents(package: EpubPackage, archive: zipfile.ZipFile) -> None:
321
+ for item_id in package.spine_ids:
322
+ item = package.manifest.get(item_id)
323
+ if item is None or item.media_type != "application/xhtml+xml":
324
+ continue
325
+ zip_path = package.resolve(item.href)
326
+ if zip_path not in package.files:
327
+ continue
328
+ data = archive.read(zip_path)
329
+ package.files[zip_path] = data
330
+ tree, recovered, encoding = parse_content_document(data, zip_path)
331
+ doc_index = len(package.docs)
332
+ doc = ContentDoc(
333
+ manifest_id=item_id,
334
+ zip_path=zip_path,
335
+ href=item.href,
336
+ tree=tree,
337
+ recovered=recovered,
338
+ reencoded_from=encoding,
339
+ )
340
+ doc.sentences = annotate_document(doc.tree, doc_index)
341
+ package.docs.append(doc)
342
+
343
+
344
+ # --- sentence extraction and id assignment ---------------------------------------------------
345
+
346
+
347
+ @dataclass
348
+ class _Item:
349
+ """One piece of a block's flat content: its own text, a child element, or a child's tail."""
350
+
351
+ kind: str
352
+ node: etree._Element
353
+ text: str
354
+ start: int
355
+
356
+ @property
357
+ def end(self) -> int:
358
+ return self.start + len(self.text)
359
+
360
+
361
+ def _collect_items(block: etree._Element) -> list[_Item]:
362
+ items: list[_Item] = []
363
+ position = 0
364
+ if block.text:
365
+ items.append(_Item("text", block, block.text, position))
366
+ position += len(block.text)
367
+ for child in block:
368
+ if not isinstance(child.tag, str):
369
+ if child.tail:
370
+ items.append(_Item("text", child, child.tail, position))
371
+ position += len(child.tail)
372
+ continue
373
+ skip = local_name(child.tag) in _SKIP_TAGS or _is_marker(child)
374
+ text = "" if skip else "".join(child.itertext())
375
+ items.append(_Item("elem", child, text, position))
376
+ position += len(text)
377
+ if child.tail:
378
+ items.append(_Item("text", child, child.tail, position))
379
+ position += len(child.tail)
380
+ return items
381
+
382
+
383
+ def _piece_text(piece: str | etree._Element) -> str:
384
+ """The narratable text of one child or text fragment, empty for anything not read aloud."""
385
+ if isinstance(piece, str):
386
+ return piece
387
+ if not isinstance(piece.tag, str):
388
+ return ""
389
+ if local_name(piece.tag) in _SKIP_TAGS or _is_marker(piece):
390
+ return ""
391
+ return "".join(piece.itertext())
392
+
393
+
394
+ def _blocks_and_loose_runs(element: etree._Element) -> list[etree._Element]:
395
+ """The block children of ``element``, in order, with a holder for each run of text beside them.
396
+
397
+ Text sitting directly in ``<li>one<ol>…</ol></li>`` belongs to no block of its own, and
398
+ without a holder the walk descends past it and the narration skips it. A run that is already
399
+ a single element is used as it stands, so the markup only gains a span for bare text.
400
+ """
401
+ pieces: list[str | etree._Element] = []
402
+ if element.text:
403
+ pieces.append(element.text)
404
+ for child in element:
405
+ pieces.append(child)
406
+ if child.tail:
407
+ pieces.append(child.tail)
408
+
409
+ namespace = _namespace_of(element)
410
+ sequence: list[str | etree._Element] = []
411
+ blocks: list[etree._Element] = []
412
+ run: list[str | etree._Element] = []
413
+
414
+ def flush() -> None:
415
+ if any(_piece_text(piece).strip() for piece in run):
416
+ carriers = [piece for piece in run if not isinstance(piece, str)]
417
+ bare = any(isinstance(piece, str) and piece.strip() for piece in run)
418
+ if len(carriers) == 1 and not bare:
419
+ blocks.append(carriers[0])
420
+ sequence.extend(run)
421
+ else:
422
+ span = etree.Element(f"{namespace}span")
423
+ _fill(span, list(run))
424
+ blocks.append(span)
425
+ sequence.append(span)
426
+ else:
427
+ sequence.extend(run)
428
+ run.clear()
429
+
430
+ for piece in pieces:
431
+ if not isinstance(piece, str) and local_name(piece.tag) in _BLOCK_TAGS:
432
+ flush()
433
+ sequence.append(piece)
434
+ blocks.append(piece)
435
+ else:
436
+ run.append(piece)
437
+ flush()
438
+ _fill(element, sequence)
439
+ return blocks
440
+
441
+
442
+ def _iter_text_blocks(element: etree._Element):
443
+ """Yield the deepest elements that hold running text, skipping non-readable subtrees."""
444
+ tag = local_name(element.tag)
445
+ if tag in _SKIP_TAGS or tag in _ATOMIC_TAGS:
446
+ return
447
+ has_blocks = any(
448
+ isinstance(child.tag, str) and local_name(child.tag) in _BLOCK_TAGS for child in element
449
+ )
450
+ if not has_blocks:
451
+ if "".join(element.itertext()).strip():
452
+ yield element
453
+ return
454
+ for block in _blocks_and_loose_runs(element):
455
+ if local_name(block.tag) in _BLOCK_TAGS:
456
+ yield from _iter_text_blocks(block)
457
+ else:
458
+ yield block
459
+
460
+
461
+ def _snap_to_elements(boundaries: list[int], items: list[_Item]) -> list[int]:
462
+ """Move split points that land inside a child element out to that element's edge.
463
+
464
+ A sentence starting or ending inside ``<i>…</i>`` cannot be wrapped in a single span without
465
+ breaking the markup, so it merges with its neighbour instead.
466
+ """
467
+ snapped: list[int] = []
468
+ for boundary in boundaries:
469
+ for item in items:
470
+ if item.kind == "elem" and item.start < boundary < item.end:
471
+ boundary = item.end
472
+ break
473
+ if not snapped or boundary > snapped[-1]:
474
+ snapped.append(boundary)
475
+ return snapped
476
+
477
+
478
+ def element_ids(tree: etree._ElementTree) -> set[str]:
479
+ """Every id already in a content document, so generated ones never collide with them."""
480
+ return {
481
+ element.get("id")
482
+ for element in tree.getroot().iter()
483
+ if isinstance(element.tag, str) and element.get("id")
484
+ }
485
+
486
+
487
+ def _unique_id(candidate: str, taken: set[str]) -> str:
488
+ identifier = _ID_SAFE.sub("-", candidate)
489
+ if identifier in taken:
490
+ suffix = 2
491
+ while f"{identifier}-{suffix}" in taken:
492
+ suffix += 1
493
+ identifier = f"{identifier}-{suffix}"
494
+ taken.add(identifier)
495
+ return identifier
496
+
497
+
498
+ def _slice_items(items: list[_Item], start: int, end: int) -> list[str | etree._Element]:
499
+ """The content of character range ``[start, end)`` as text fragments and whole elements."""
500
+ pieces: list[str | etree._Element] = []
501
+ for item in items:
502
+ if item.start == item.end:
503
+ # An element contributing no text (a note marker, a page break) has no range to
504
+ # overlap, so it is placed by position instead. Without this it falls between two
505
+ # slices and is dropped.
506
+ if item.kind == "elem" and start <= item.start < end:
507
+ pieces.append(item.node)
508
+ continue
509
+ if item.end <= start or item.start >= end:
510
+ continue
511
+ if item.kind == "elem":
512
+ pieces.append(item.node)
513
+ else:
514
+ pieces.append(
515
+ item.text[max(0, start - item.start): min(len(item.text), end - item.start)]
516
+ )
517
+ return pieces
518
+
519
+
520
+ def _fill(container: etree._Element, pieces: list[str | etree._Element]) -> None:
521
+ container.text = None
522
+ for child in list(container):
523
+ container.remove(child)
524
+ for piece in pieces:
525
+ if isinstance(piece, str):
526
+ if not piece:
527
+ continue
528
+ if len(container):
529
+ container[-1].tail = (container[-1].tail or "") + piece
530
+ else:
531
+ container.text = (container.text or "") + piece
532
+ else:
533
+ piece.tail = None
534
+ container.append(piece)
535
+
536
+
537
+ def _structure_type(element: etree._Element) -> str | None:
538
+ """The epub:type for the seq mirroring this element, or None if it needs no seq of its own."""
539
+ for value in (element.get(f"{{{EPUB_NS}}}type") or "").split():
540
+ if value in SKIPPABLE_TYPES:
541
+ return value
542
+ return ESCAPABLE_TAGS.get(local_name(element.tag))
543
+
544
+
545
+ def _structure_chain(
546
+ block: etree._Element, body: etree._Element, label: int, taken: set[str], groups: int
547
+ ) -> tuple[tuple[Structure, ...], int]:
548
+ """Structures enclosing one block, outermost first, giving each an id where it has none."""
549
+ chain: list[Structure] = []
550
+ node: etree._Element | None = block
551
+ while node is not None and node is not body and isinstance(node.tag, str):
552
+ epub_type = _structure_type(node)
553
+ if epub_type is not None:
554
+ identifier = node.get("id")
555
+ if not identifier:
556
+ groups += 1
557
+ identifier = _unique_id(f"ra-{label}-group-{groups}", taken)
558
+ node.set("id", identifier)
559
+ chain.append(Structure(epub_type, identifier))
560
+ node = node.getparent()
561
+ chain.reverse()
562
+ return tuple(chain), groups
563
+
564
+
565
+ def _rewrite_block(
566
+ block: etree._Element,
567
+ items: list[_Item],
568
+ units: list[tuple[int, int]],
569
+ label: int,
570
+ counter: int,
571
+ taken: set[str],
572
+ ) -> tuple[list[tuple[str, str]], int]:
573
+ """Give every unit in ``block`` an id, wrapping in spans only where one is needed."""
574
+ total = items[-1].end if items else 0
575
+ joined = "".join(item.text for item in items)
576
+ results: list[tuple[str, str]] = []
577
+ if len(units) == 1 and not joined[: units[0][0]].strip() and not joined[units[0][1]:].strip():
578
+ identifier = block.get("id")
579
+ if not identifier:
580
+ counter += 1
581
+ identifier = _unique_id(f"ra-{label}-{counter}", taken)
582
+ block.set("id", identifier)
583
+ return [(identifier, joined[units[0][0]: units[0][1]])], counter
584
+
585
+ # Detaching children first means moving them into spans cannot disturb the sibling walk.
586
+ children = list(block)
587
+ for child in children:
588
+ block.remove(child)
589
+
590
+ sequence: list[str | etree._Element] = []
591
+ cursor = 0
592
+ namespace = _namespace_of(block)
593
+ for start, end in units:
594
+ sequence.extend(_slice_items(items, cursor, start))
595
+ pieces = _slice_items(items, start, end)
596
+ single = pieces[0] if len(pieces) == 1 else None
597
+ if single is not None and not isinstance(single, str) and single.get("id"):
598
+ identifier = single.get("id")
599
+ single.tail = None
600
+ sequence.append(single)
601
+ else:
602
+ counter += 1
603
+ identifier = _unique_id(f"ra-{label}-{counter}", taken)
604
+ span = etree.Element(f"{namespace}span")
605
+ span.set("id", identifier)
606
+ _fill(span, pieces)
607
+ sequence.append(span)
608
+ results.append((identifier, joined[start:end]))
609
+ cursor = end
610
+ # total + 1 so a zero-length element sitting at the very end of the block is kept.
611
+ sequence.extend(_slice_items(items, cursor, total + 1))
612
+ _fill(block, sequence)
613
+ return results, counter
614
+
615
+
616
+ def annotate_document(tree: etree._ElementTree, doc_index: int) -> list[Sentence]:
617
+ """Split a content document into sentences and ensure each one has a fragment id.
618
+
619
+ ``doc_index`` is the document's position in the spine, counted from zero, and is what
620
+ ``package.docs`` is indexed by. Generated ids count from one, so the first document's
621
+ sentences read ``ra-1-1`` rather than ``ra-0-1``.
622
+ """
623
+ label = doc_index + 1
624
+ taken = element_ids(tree)
625
+ root = tree.getroot()
626
+ body = next((element for element in root.iter() if local_name(element.tag) == "body"), root)
627
+ sentences: list[Sentence] = []
628
+ counter = 0
629
+ groups = 0
630
+ for block in list(_iter_text_blocks(body)):
631
+ items = _collect_items(block)
632
+ joined = "".join(item.text for item in items)
633
+ ranges = split_sentences(joined)
634
+ if not ranges:
635
+ continue
636
+ boundaries = _snap_to_elements([end for _, end in ranges[:-1]], items)
637
+ units: list[tuple[int, int]] = []
638
+ cursor = ranges[0][0]
639
+ for boundary in [*boundaries, ranges[-1][1]]:
640
+ if boundary <= cursor:
641
+ continue
642
+ # Keep the whitespace between sentences outside the spans so a highlight starts
643
+ # on the first letter rather than on the gap after the previous sentence.
644
+ start = cursor
645
+ while start < boundary and joined[start].isspace():
646
+ start += 1
647
+ end = boundary
648
+ while end > start and joined[end - 1].isspace():
649
+ end -= 1
650
+ if end > start:
651
+ units.append((start, end))
652
+ cursor = boundary
653
+ if not units:
654
+ continue
655
+ structure, groups = _structure_chain(block, body, label, taken, groups)
656
+ produced, counter = _rewrite_block(block, items, units, label, counter, taken)
657
+ for identifier, text in produced:
658
+ words = text.split()
659
+ if not words:
660
+ continue
661
+ sentences.append(
662
+ Sentence(
663
+ doc_index=doc_index,
664
+ index=len(sentences),
665
+ fragment_id=identifier,
666
+ text=" ".join(words),
667
+ words=words,
668
+ structure=structure,
669
+ )
670
+ )
671
+ return sentences
readalign/errors.py ADDED
@@ -0,0 +1,27 @@
1
+ """Error types carrying the process exit code readalign should terminate with."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ReadAlignError(Exception):
7
+ """Base for every error readalign turns into a one-line message instead of a traceback."""
8
+
9
+ exit_code = 1
10
+
11
+
12
+ class InputError(ReadAlignError):
13
+ """A file the user pointed at is missing, empty or not the kind of file it claims to be."""
14
+
15
+
16
+ class DRMError(ReadAlignError):
17
+ """The input is DRM-protected. readalign refuses these by design."""
18
+
19
+ exit_code = 2
20
+
21
+
22
+ class DependencyError(ReadAlignError):
23
+ """An external prerequisite (ffmpeg, a model download) is unavailable."""
24
+
25
+
26
+ class AlignmentError(ReadAlignError):
27
+ """Nothing in the book could be matched to the narration."""