bitscuit a1796e82aa pdf-to-audiobook: PDF -> narrated MP3 via edge-tts
Pipeline: extract (pdftotext) -> scan (OCR-defect report + fix template)
-> clean (noise/header removal, hyphenation, exact-string/regex OCR
repairs, chapter-heading detection) -> voices (sample + comparison)
-> chunk (sentence-aware) -> synth (resumable edge-tts) ->
stitch (ffmpeg concat + RMS verify) -> chapters (ID3v2.4 CHAP).

Config-driven per book; validated end-to-end on a clean digital PDF
and on a 546-page scanned book (byte-identical clean output to the
reference run, 95 auto-detected chapters).
2026-08-25 14:15:50 +02:00

100 lines
3.5 KiB
Python

"""Scan extracted text for OCR defects and generate a fix-config template.
Scanned PDFs mangle text in systematic ways:
- U+FFFD (replacement character) where the OCR engine gave up, usually on
diacritics and unusual consonants (foreign-language terms, umlauts, ...)
- Confused characters: ; -> s/h, J -> th/v, 9 -> d, ! -> l, < -> sh, ...
The OCR engine of a given scan misreads the SAME glyph the SAME way, so the
right tool is an exact-string replacement map built from the actual output.
`scan` enumerates the distinct suspicious tokens with counts and surrounding
context; you (or a language model) then fill in the correct reading, and
`clean` applies the map.
Output of scan:
- a human-readable report on stdout
- optionally a fix-config template JSON with the suspicious tokens as
empty values, ready to be filled in: {"fixes": {"mangled-token": ""}}
"""
import json
import re
import sys
FFFD = "\ufffd"
# Characters that rarely appear inside a real English word. A token that has
# one of these in the MIDDLE of the word (or FFFD anywhere) is suspect.
SUSPECT = set(";<>![]\\_`|~^@#%&*+=")
TOKEN_RE = re.compile(r"[\w" + FFFD + r";<>!\\[\]_`\-]+")
def is_suspect(token: str) -> bool:
if FFFD in token:
return True
body = token.strip("-_")
if len(body) < 3:
return False
# suspect char inside the word (not a leading/trailing punctuation)
return any(c in SUSPECT for c in body[1:-1])
def scan(text: str, top: int = 300) -> list:
"""Return [(count, token, context)] sorted by count desc, then token."""
counts = {}
context = {}
for m in TOKEN_RE.finditer(text):
tok = m.group(0)
if not is_suspect(tok):
continue
counts[tok] = counts.get(tok, 0) + 1
if tok not in context:
s = max(0, m.start() - 40)
e = min(len(text), m.end() + 40)
ctx = text[s:e].replace("\n", " ")
context[tok] = ctx
items = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
return [(c, t, context[t]) for t, c in items[:top]]
def cmd_scan(path: str, top: int = 300, template: str = None) -> None:
text = open(path, encoding="utf-8").read()
total_fffd = text.count(FFFD)
words = len(text.split())
items = scan(text, top=top)
print(f"text: {path}")
print(f"words: {words} U+FFFD glyphs: {total_fffd} "
f"suspect tokens (distinct): {len(items)}")
print()
print(f"{'count':>6} {'token':<32} context")
print("-" * 100)
for count, tok, ctx in items:
print(f"{count:>6} {tok!r:<32} ...{ctx}...")
if template:
fixes = {tok: "" for _, tok, _ in items}
cfg = {
"include": {"start": None, "end": None},
"drop_blocks": [],
"page_header_patterns": [],
"drop_line_patterns": [],
"strip_patterns": [],
"raw_fixes": {},
"regex_fixes": [],
"word_fixes": [],
"fixes": fixes,
"heading_pattern": None,
"heading_count_max": 150,
}
with open(template, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
print(f"\nfix-config template written to {template} "
f"({len(fixes)} tokens to review). Fill in the empty values "
f"with the correct reading; empty values are ignored by clean.")
print("See README 'Fixing OCR defects' for the workflow.")
if __name__ == "__main__":
cmd_scan(sys.argv[1])