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/__init__.py +3 -0
- readalign/__main__.py +8 -0
- readalign/align.py +340 -0
- readalign/asr.py +277 -0
- readalign/audio.py +362 -0
- readalign/cli.py +495 -0
- readalign/clock.py +42 -0
- readalign/epub.py +671 -0
- readalign/errors.py +27 -0
- readalign/fs.py +22 -0
- readalign/members.py +72 -0
- readalign/package.py +287 -0
- readalign/refine.py +175 -0
- readalign/report.py +153 -0
- readalign/sentences.py +78 -0
- readalign/sidecar.py +142 -0
- readalign/smil.py +77 -0
- readalign/textnorm.py +158 -0
- readalign-1.0.0.dist-info/METADATA +332 -0
- readalign-1.0.0.dist-info/RECORD +23 -0
- readalign-1.0.0.dist-info/WHEEL +4 -0
- readalign-1.0.0.dist-info/entry_points.txt +2 -0
- readalign-1.0.0.dist-info/licenses/LICENSE +21 -0
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."""
|