pdftts 0.2.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.
pdftts/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """pdftts — turn PDFs and pasted text into spoken audio, entirely offline."""
2
+
3
+ from . import engines
4
+ from .core import Document, from_pdf, from_text, render
5
+ from .device import probe
6
+
7
+ __version__ = "0.2.0"
8
+ __all__ = ["Document", "from_pdf", "from_text", "render", "engines", "probe"]
pdftts/audio.py ADDED
@@ -0,0 +1,139 @@
1
+ """Post-processing: compressed delivery formats and playback."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ def have_ffmpeg() -> bool:
10
+ return shutil.which("ffmpeg") is not None
11
+
12
+
13
+ #: ISO 639-1 -> 639-2/B. EPUB declares two-letter codes; MP4 stores three, and
14
+ #: ffmpeg drops a two-letter value without a word. Covers every language pdftts
15
+ #: can narrate plus the common European ones a library is likely to contain;
16
+ #: anything unmapped is left off rather than written wrong.
17
+ _ISO_639_2 = {
18
+ "en": "eng", "es": "spa", "fr": "fre", "hi": "hin", "it": "ita", "pt": "por",
19
+ "ja": "jpn", "zh": "chi", "de": "ger", "nl": "dut", "ru": "rus", "pl": "pol",
20
+ "sv": "swe", "da": "dan", "no": "nor", "fi": "fin", "tr": "tur", "ar": "ara",
21
+ "ko": "kor", "cs": "cze", "el": "gre", "he": "heb", "hu": "hun", "ro": "ron",
22
+ "uk": "ukr", "vi": "vie", "id": "ind", "th": "tha", "ca": "cat", "la": "lat",
23
+ }
24
+
25
+
26
+ def iso_639_2(code: str) -> str:
27
+ """Three-letter form of a language code, or "" when it cannot be mapped."""
28
+ code = (code or "").strip().lower().replace("_", "-").split("-")[0]
29
+ if len(code) == 3:
30
+ return code
31
+ return _ISO_639_2.get(code, "")
32
+
33
+
34
+ def _tag_args(tags: dict) -> list[str]:
35
+ """ffmpeg -metadata flags. Language belongs to the audio stream in MP4:
36
+ passed as a container tag it is silently dropped."""
37
+ out: list[str] = []
38
+ for key, value in tags.items():
39
+ if key == "language":
40
+ value = iso_639_2(value)
41
+ if not value:
42
+ continue
43
+ out += ["-metadata:s:a:0" if key == "language" else "-metadata", f"{key}={value}"]
44
+ return out
45
+
46
+
47
+ def to_m4a(wav: Path, out: Path | None = None, bitrate: str = "64k", **tags: str) -> Path:
48
+ """Convert to a tagged m4a — a 47-minute WAV is 130 MB, the m4a is 24 MB."""
49
+ if not have_ffmpeg():
50
+ raise RuntimeError("ffmpeg is required for m4a output (brew install ffmpeg)")
51
+ out = out or wav.with_suffix(".m4a")
52
+ cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", str(wav),
53
+ "-c:a", "aac", "-b:a", bitrate, "-ac", "1"]
54
+ cmd += _tag_args(tags)
55
+ cmd.append(str(out))
56
+ subprocess.run(cmd, check=True)
57
+ return out
58
+
59
+
60
+ def _cover_file(cover: bytes, near: Path) -> Path | None:
61
+ """Write jacket bytes beside the output so ffmpeg can read them as an input."""
62
+ if not cover:
63
+ return None
64
+ kind = "png" if cover[:8] == b"\x89PNG\r\n\x1a\n" else "jpg"
65
+ path = near.with_name(f".{near.stem}-cover.{kind}")
66
+ path.write_bytes(cover)
67
+ return path
68
+
69
+
70
+ def to_m4b(wav: Path, out: Path | None = None, chapters: str = "",
71
+ bitrate: str = "64k", cover: bytes = b"", **tags: str) -> Path:
72
+ """Convert to a chaptered m4b — the format audiobook players expect.
73
+
74
+ An m4b remembers your position and exposes chapter navigation; an m4a of the
75
+ same audio does neither. A jacket image is attached as a still video stream
76
+ flagged `attached_pic`, which is how players find cover art in an MP4
77
+ container; without the flag they treat it as a video track and some refuse
78
+ to play the file at all.
79
+ """
80
+ if not have_ffmpeg():
81
+ raise RuntimeError("ffmpeg is required for m4b output (brew install ffmpeg)")
82
+ out = out or wav.with_suffix(".m4b")
83
+ cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", str(wav)]
84
+ meta: Path | None = None
85
+ art = _cover_file(cover, out)
86
+ next_input = 1
87
+ meta_input = art_input = None
88
+ if chapters:
89
+ meta = out.with_suffix(".ffmeta")
90
+ meta.write_text(chapters)
91
+ cmd += ["-i", str(meta)]
92
+ meta_input = next_input
93
+ next_input += 1
94
+ if art:
95
+ cmd += ["-i", str(art)]
96
+ art_input = next_input
97
+ cmd += ["-map", "0:a"]
98
+ if art_input is not None:
99
+ cmd += ["-map", f"{art_input}:v", "-c:v", "mjpeg", "-disposition:v:0", "attached_pic"]
100
+ if meta_input is not None:
101
+ cmd += ["-map_metadata", str(meta_input), "-map_chapters", str(meta_input)]
102
+ cmd += ["-c:a", "aac", "-b:a", bitrate, "-ac", "1"]
103
+ cmd += _tag_args(tags)
104
+ cmd.append(str(out))
105
+ try:
106
+ subprocess.run(cmd, check=True)
107
+ finally:
108
+ for scratch in (meta, art):
109
+ if scratch and scratch.exists():
110
+ scratch.unlink()
111
+ return out
112
+
113
+
114
+ def convert(wav: Path, fmt: str, out: Path | None = None, bitrate: str = "64k",
115
+ **tags: str) -> Path:
116
+ """Encode to mp3/flac/opus/m4a. Lossless formats ignore the bitrate."""
117
+ codecs = {"mp3": "libmp3lame", "flac": "flac", "opus": "libopus", "m4a": "aac"}
118
+ if fmt not in codecs:
119
+ raise ValueError(f"unsupported format {fmt!r}; choose from {', '.join(codecs)}")
120
+ if not have_ffmpeg():
121
+ raise RuntimeError("ffmpeg is required for compressed output")
122
+ out = out or wav.with_suffix(f".{fmt}")
123
+ cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", str(wav),
124
+ "-c:a", codecs[fmt], "-ac", "1"]
125
+ if fmt != "flac":
126
+ cmd += ["-b:a", bitrate]
127
+ cmd += _tag_args(tags)
128
+ cmd.append(str(out))
129
+ subprocess.run(cmd, check=True)
130
+ return out
131
+
132
+
133
+ def play(path: Path, rate: float = 1.0) -> None:
134
+ """Play through whatever the platform provides; never fatal."""
135
+ for cmd in (["afplay", "-r", str(rate), str(path)],
136
+ ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(path)]):
137
+ if shutil.which(cmd[0]):
138
+ subprocess.run(cmd)
139
+ return
pdftts/batch.py ADDED
@@ -0,0 +1,81 @@
1
+ """Convert a whole folder in one run.
2
+
3
+ A shelf of books is the normal case for anyone who wants an audiobook library,
4
+ and doing it one command at a time means babysitting a machine for hours. The
5
+ rule here is that one bad file must not cost you the queue: every source is
6
+ tried, failures are recorded and reported at the end, and the run keeps going.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable, Iterator
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from . import documents
15
+
16
+
17
+ @dataclass
18
+ class Result:
19
+ source: Path
20
+ out: Path | None = None
21
+ minutes: float = 0.0
22
+ reused: int = 0
23
+ error: str = ""
24
+
25
+ @property
26
+ def ok(self) -> bool:
27
+ return self.error == ""
28
+
29
+
30
+ def sources(target: Path, recursive: bool = False) -> list[Path]:
31
+ """Every readable document under `target`, in a stable order.
32
+
33
+ Hidden files and the artefacts of an earlier run (audio, subtitles) are
34
+ skipped, so pointing this at the same folder twice does not try to narrate
35
+ the narrations.
36
+ """
37
+ if target.is_file():
38
+ return [target]
39
+ walk: Iterable[Path] = target.rglob("*") if recursive else target.iterdir()
40
+ found = [
41
+ p for p in walk
42
+ if p.is_file()
43
+ and p.suffix.lower() in documents.SUFFIXES
44
+ and not p.name.startswith(".")
45
+ ]
46
+ return sorted(found)
47
+
48
+
49
+ def plan(targets: Iterable[Path], recursive: bool = False) -> list[Path]:
50
+ """Flatten several files and folders into one de-duplicated work list."""
51
+ seen: dict[Path, None] = {}
52
+ for target in targets:
53
+ for path in sources(target, recursive=recursive):
54
+ seen.setdefault(path.resolve(), None)
55
+ return list(seen)
56
+
57
+
58
+ def summarise(results: list[Result]) -> str:
59
+ done = [r for r in results if r.ok]
60
+ failed = [r for r in results if not r.ok]
61
+ minutes = sum(r.minutes for r in done)
62
+ reused = sum(r.reused for r in done)
63
+ line = f"{len(done)}/{len(results)} converted, {minutes:.0f} min of audio"
64
+ if reused:
65
+ line += f" ({reused} chunks reused from cache)"
66
+ if failed:
67
+ line += "\nfailed:\n" + "\n".join(f" {r.source.name}: {r.error}" for r in failed)
68
+ return line
69
+
70
+
71
+ def run(paths: list[Path], convert, on_start=None) -> Iterator[Result]:
72
+ """Apply `convert` to each path, turning any failure into a Result."""
73
+ for path in paths:
74
+ if on_start:
75
+ on_start(path)
76
+ try:
77
+ yield convert(path)
78
+ except KeyboardInterrupt:
79
+ raise
80
+ except Exception as exc:
81
+ yield Result(source=path, error=f"{type(exc).__name__}: {exc}")
pdftts/cache.py ADDED
@@ -0,0 +1,130 @@
1
+ """Per-chunk render cache, so an interrupted book resumes instead of restarting.
2
+
3
+ A long render is a sequence of independent chunks, each costing real seconds of
4
+ CPU. If the process dies at chunk 900 of 1000 — a laptop lid, a dropped SSH
5
+ session, a Ctrl-C — there is no reason to pay for the first 899 again. Every
6
+ finished chunk is written here keyed by exactly the inputs that determine its
7
+ audio, so a re-run replays what is already on disk and synthesizes only the rest.
8
+
9
+ The key deliberately includes voice and speed: changing either changes the audio,
10
+ and silently reusing a stale chunk would be worse than re-rendering it.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import shutil
17
+ import sys
18
+ import time
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+
23
+ def _root() -> Path:
24
+ if sys.platform == "darwin":
25
+ base = Path.home() / "Library" / "Caches" / "pdftts"
26
+ elif sys.platform.startswith("win"):
27
+ base = Path.home() / "AppData" / "Local" / "pdftts" / "cache"
28
+ else:
29
+ import os
30
+
31
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "pdftts"
32
+ return base / "chunks"
33
+
34
+
35
+ ROOT = _root()
36
+
37
+
38
+ def key(text: str, engine: str, voice: str, speed: float, sample_rate: int) -> str:
39
+ """Everything that changes the audio, and nothing that does not."""
40
+ seed = "\x1f".join([engine, voice, f"{speed:.4f}", str(sample_rate), text])
41
+ return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32]
42
+
43
+
44
+ @dataclass
45
+ class Store:
46
+ """A cache rooted at `root`. Disabled stores are no-ops, so callers need no branches."""
47
+ root: Path = ROOT
48
+ enabled: bool = True
49
+ hits: int = 0
50
+ misses: int = 0
51
+
52
+ def _paths(self, digest: str) -> tuple[Path, Path]:
53
+ # One subdirectory per two hex chars keeps any single directory small.
54
+ shard = self.root / digest[:2]
55
+ return shard / f"{digest}.npy", shard / f"{digest}.json"
56
+
57
+ def get(self, digest: str):
58
+ """Return (samples, words) if this chunk was rendered before, else None."""
59
+ if not self.enabled:
60
+ return None
61
+ wav_path, meta_path = self._paths(digest)
62
+ if not (wav_path.exists() and meta_path.exists()):
63
+ self.misses += 1
64
+ return None
65
+ try:
66
+ import numpy as np
67
+
68
+ samples = np.load(wav_path)
69
+ words = [tuple(w) for w in json.loads(meta_path.read_text())["words"]]
70
+ except Exception:
71
+ # A half-written or corrupt entry is a miss, never a crash.
72
+ wav_path.unlink(missing_ok=True)
73
+ meta_path.unlink(missing_ok=True)
74
+ self.misses += 1
75
+ return None
76
+ self.hits += 1
77
+ return samples, words
78
+
79
+ def put(self, digest: str, samples, words: list) -> None:
80
+ if not self.enabled:
81
+ return
82
+ wav_path, meta_path = self._paths(digest)
83
+ wav_path.parent.mkdir(parents=True, exist_ok=True)
84
+ try:
85
+ import numpy as np
86
+
87
+ # Write to a temp name and rename, so a kill mid-write cannot leave
88
+ # a truncated .npy that later reads back as valid-looking audio.
89
+ # np.save() appends ".npy" to a *path* that lacks it, which would
90
+ # defeat the rename — hand it an open file object instead.
91
+ tmp = wav_path.with_name(wav_path.name + ".part")
92
+ with tmp.open("wb") as fh:
93
+ np.save(fh, samples)
94
+ tmp.replace(wav_path)
95
+ meta_path.write_text(json.dumps(
96
+ {"words": [list(w) for w in words], "saved": time.time()}))
97
+ except Exception:
98
+ for scratch in (wav_path, meta_path, wav_path.with_name(wav_path.name + ".part")):
99
+ scratch.unlink(missing_ok=True)
100
+
101
+ def usage(self) -> int:
102
+ if not self.root.exists():
103
+ return 0
104
+ return sum(f.stat().st_size for f in self.root.rglob("*") if f.is_file())
105
+
106
+ def clear(self) -> int:
107
+ freed = self.usage()
108
+ shutil.rmtree(self.root, ignore_errors=True)
109
+ return freed
110
+
111
+
112
+ def prune(older_than_days: float = 30.0, root: Path = ROOT) -> int:
113
+ """Drop entries untouched for a while. Returns bytes freed."""
114
+ if not root.exists():
115
+ return 0
116
+ cutoff = time.time() - older_than_days * 86_400
117
+ freed = 0
118
+ for meta_path in root.rglob("*.json"):
119
+ try:
120
+ saved = json.loads(meta_path.read_text()).get("saved", 0)
121
+ except Exception:
122
+ saved = 0
123
+ if saved >= cutoff:
124
+ continue
125
+ wav_path = meta_path.with_suffix(".npy")
126
+ for f in (wav_path, meta_path):
127
+ if f.exists():
128
+ freed += f.stat().st_size
129
+ f.unlink()
130
+ return freed
pdftts/chapters.py ADDED
@@ -0,0 +1,37 @@
1
+ """Map document chapters onto the finished audio timeline."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class Marker:
9
+ title: str
10
+ start: float
11
+ end: float
12
+
13
+ def as_dict(self) -> dict:
14
+ return {"title": self.title, "start": round(self.start, 3),
15
+ "end": round(self.end, 3)}
16
+
17
+
18
+ def markers(titles: list[str], part_chapter: list[int],
19
+ part_spans: list[tuple[float, float]]) -> list[Marker]:
20
+ """Turn per-chunk chapter ownership into one time span per chapter."""
21
+ out: list[Marker] = []
22
+ for idx, title in enumerate(titles):
23
+ mine = [span for span, owner in zip(part_spans, part_chapter) if owner == idx]
24
+ if not mine:
25
+ continue
26
+ out.append(Marker(title, mine[0][0], mine[-1][1]))
27
+ return out
28
+
29
+
30
+ def ffmetadata(marks: list[Marker]) -> str:
31
+ """ffmpeg chapter metadata — what makes an m4b navigable in a player."""
32
+ lines = [";FFMETADATA1"]
33
+ for m in marks:
34
+ lines += ["[CHAPTER]", "TIMEBASE=1/1000",
35
+ f"START={int(m.start * 1000)}", f"END={int(m.end * 1000)}",
36
+ f"title={m.title}"]
37
+ return "\n".join(lines) + "\n"
pdftts/chunk.py ADDED
@@ -0,0 +1,99 @@
1
+ """Split text into synthesizer-sized pieces at real sentence boundaries.
2
+
3
+ Naive splitting on `[.!?]` breaks inside initials and list markers, which is
4
+ inaudible as text and very audible as speech: the synthesizer treats "…by T. S."
5
+ as a finished sentence, drops its pitch, pauses, and then restarts on "Eliot".
6
+ Across a chapter that reads as constant glitching.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ # Kokoro degrades on very long inputs; ~380 characters keeps prosody natural
13
+ # while staying long enough that sentences are not clipped into fragments.
14
+ DEFAULT_LIMIT = 380
15
+ MIN_CHUNK = 60 # anything shorter gets folded into a neighbour
16
+
17
+ _SENTINEL = "\x00"
18
+
19
+ # Periods that do NOT end a sentence.
20
+ _ABBREV = re.compile(
21
+ r"""(?:
22
+ \b[A-Z]\. # initials: T. S. Eliot
23
+ | \b(?:Mr|Mrs|Ms|Dr|Prof|Rev|St|Jr|Sr|Hon|Gen|Col|Capt)\.
24
+ | \b(?:vs|etc|cf|ibid|al|Inc|Ltd|Co)\.
25
+ | \b(?:i\.e|e\.g|a\.m|p\.m|A\.D|B\.C)\.
26
+ | \b(?:ch|chap|pp|p|vol|no|fig|ed|trans)\. # citation shorthand
27
+ )""",
28
+ re.X,
29
+ )
30
+
31
+ # List markers ("1.", "II.") at the start of a line or straight after a sentence.
32
+ # Matched with the preceding context rather than a lookbehind, because the
33
+ # context is variable width; only the marker's own period is neutralised.
34
+ _MARKER = re.compile(r"(^|[.!?][\"'\u201d]?\s)(\s*)((?:\d{1,3}|[IVXivx]{1,5}))\.", re.M)
35
+
36
+ _SPLIT = re.compile(r"(?<=[.!?])[\"'\u201d\u2019)]?\s+|\n\n+")
37
+
38
+
39
+ def _protect(text: str) -> str:
40
+ text = _ABBREV.sub(lambda m: m.group(0).replace(".", _SENTINEL), text)
41
+ return _MARKER.sub(lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}{_SENTINEL}", text)
42
+
43
+
44
+ def _restore(text: str) -> str:
45
+ return text.replace(_SENTINEL, ".")
46
+
47
+
48
+ def _merge_runts(parts: list[str], limit: int) -> list[str]:
49
+ """Fold stubs into a neighbour — a two-word chunk gets read as its own breath."""
50
+ out: list[str] = []
51
+ for part in parts:
52
+ if out and len(part) < MIN_CHUNK and len(out[-1]) + len(part) + 1 <= limit:
53
+ out[-1] = f"{out[-1]} {part}"
54
+ else:
55
+ out.append(part)
56
+ # A short final chunk has no successor to absorb it, so pull it backwards.
57
+ if len(out) > 1 and len(out[-1]) < MIN_CHUNK:
58
+ tail = out.pop()
59
+ out[-1] = f"{out[-1]} {tail}"
60
+ return out
61
+
62
+
63
+ def chunks(text: str, limit: int = DEFAULT_LIMIT) -> list[str]:
64
+ protected = _protect(text)
65
+ out: list[str] = []
66
+ buf = ""
67
+
68
+ for piece in _SPLIT.split(protected):
69
+ piece = (piece or "").strip()
70
+ if not piece:
71
+ continue
72
+ if len(piece) > limit: # a sentence longer than the cap
73
+ for part in re.split(r"(?<=[,;:])\s+", piece):
74
+ if len(buf) + len(part) + 1 > limit and buf:
75
+ out.append(buf)
76
+ buf = part
77
+ else:
78
+ buf = f"{buf} {part}".strip()
79
+ continue
80
+ if len(buf) + len(piece) + 1 > limit and buf:
81
+ out.append(buf)
82
+ buf = piece
83
+ else:
84
+ buf = f"{buf} {piece}".strip()
85
+ if buf:
86
+ out.append(buf)
87
+
88
+ return [_restore(p) for p in _merge_runts(out, limit)]
89
+
90
+
91
+ def sentences(text: str) -> list[str]:
92
+ """Split into sentences without breaking initials or list markers."""
93
+ parts = [_restore(p).strip() for p in _SPLIT.split(_protect(text))]
94
+ return [p for p in parts if p]
95
+
96
+
97
+ def estimate_minutes(text: str, speed: float = 1.0) -> float:
98
+ """Rough runtime, calibrated against measured Kokoro output (~925 chars/min)."""
99
+ return len(text) / 925.0 / max(speed, 0.1)
pdftts/clean.py ADDED
@@ -0,0 +1,100 @@
1
+ """Turn extracted page text into something worth listening to.
2
+
3
+ Everything here exists because it was audible: a folio read out mid-sentence,
4
+ a drop cap pronounced as a letter, a vocabulary list run into the next
5
+ paragraph. Print artifacts are invisible on the page and glaring in audio.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import collections
10
+ import re
11
+
12
+
13
+ def _folio_key(line: str) -> str:
14
+ """Normalise a running head so 'Title 175' and 'Title 177' compare equal."""
15
+ return re.sub(r"^\d{1,4}\s*|\s*\d{1,4}$", "", line).strip()
16
+
17
+
18
+ def strip_running_heads(text: str, min_repeats: int = 3) -> str:
19
+ """Drop page numbers and headers/footers that repeat across pages."""
20
+ lines = text.split("\n")
21
+ counts = collections.Counter(_folio_key(l.strip()) for l in lines if l.strip())
22
+
23
+ def keep(line: str) -> bool:
24
+ t = line.strip()
25
+ if not t:
26
+ return True
27
+ if re.fullmatch(r"[ivxlcdm\d]{1,6}[.,]?", t, re.I): # bare folio
28
+ return False
29
+ if len(t) == 1 and not t.isalpha(): # stray * or dagger
30
+ return False
31
+ key = _folio_key(t)
32
+ return not (key and len(t) < 60 and counts[key] >= min_repeats)
33
+
34
+ return "\n".join(l for l in lines if keep(l))
35
+
36
+
37
+ def fix_drop_caps(text: str) -> str:
38
+ """Rejoin a decorative initial split off from its word.
39
+
40
+ Scanners read a drop cap as its own line and usually mangle the letter
41
+ that follows it, so 'W\\nTe are' is really 'We are'.
42
+ """
43
+ text = re.sub(r"^([A-Z])\n([A-Z])(?=[a-z])", r"\1", text, flags=re.M)
44
+ return re.sub(r"^([A-Z])\n(?=[a-z])", r"\1", text, flags=re.M)
45
+
46
+
47
+ def punctuate_word_lists(text: str) -> str:
48
+ """Give vocabulary lists sentence breaks so they are not read as prose."""
49
+ def repl(m: re.Match) -> str:
50
+ words = [w.strip() for w in m.group(0).strip().split("\n") if w.strip()]
51
+ return "\n\n" + ".\n".join(words) + ".\n\n"
52
+
53
+ return re.sub(r"(?:^[A-Za-z][\w'-]*\.?$\n){3,}", repl, text, flags=re.M)
54
+
55
+
56
+ def strip_footnote_markers(text: str) -> str:
57
+ """Remove reference numerals that would otherwise be read as digits."""
58
+ text = re.sub(r"\[\d{1,3}\]", "", text) # [12]
59
+ text = re.sub(r"(?<=[.,;:!?\"'])\s*\d{1,3}(?=\s|$)", "", text) # trailing 108
60
+ return re.sub(r"\s+\*(?=\s|$)", "", text) # lone asterisk
61
+
62
+
63
+ def normalize_for_speech(text: str) -> str:
64
+ """Remove or spell out characters the synthesizer mangles.
65
+
66
+ Stray typographic marks left by extraction (bullets, tildes, brackets around
67
+ editorial insertions) are silent on the page but come out as clicks, pauses
68
+ or literal words in the audio.
69
+ """
70
+ text = text.replace("\u00b7", " ").replace("~", " ").replace("\u2022", " ")
71
+ text = re.sub(r"[\[\]{}<>|_*^]", " ", text)
72
+ text = re.sub(r"(?<=\w)/(?=\w)", " or ", text) # and/or -> and or
73
+ text = re.sub(r"(?<!\w)/(?!\w)", " ", text) # stray slashes from bad OCR
74
+ # Punctuation debris left where extraction mangled a heading, e.g. ( /" —
75
+ # silent on the page, audible as clicks and false pauses.
76
+ text = re.sub(r"(?<=\s)[^\w\s]{2,}(?=\s)", " ", text)
77
+ text = re.sub(r"\(\s*(?=[^\w(]*\s)", " ", text)
78
+ text = text.replace("&", " and ")
79
+ text = re.sub(r"\.{3,}", ", ", text) # ellipses read as pauses
80
+ text = re.sub(r"(?<=[a-zA-Z])-{2,}(?=[a-zA-Z])", ", ", text)
81
+ return re.sub(r"[ \t]{2,}", " ", text)
82
+
83
+
84
+ def unwrap(text: str) -> str:
85
+ """Undo print line-wrapping so sentences reach the synthesizer whole."""
86
+ text = text.replace("­", "") # soft hyphen
87
+ text = re.sub(r"(\w)-\n(?=\w)", r"\1", text) # de-hyphenate
88
+ text = re.sub(r"(?<![.!?:;\n])\n(?!\n)", " ", text) # join soft wraps
89
+ text = re.sub(r"[ \t]+", " ", text)
90
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
91
+
92
+
93
+ def clean(text: str) -> str:
94
+ """Full pipeline, in the order the fixes depend on each other."""
95
+ text = strip_running_heads(text)
96
+ text = fix_drop_caps(text)
97
+ text = punctuate_word_lists(text)
98
+ text = strip_footnote_markers(text)
99
+ text = normalize_for_speech(text)
100
+ return unwrap(text)