Fix paragraph breaks, drop caps, headers and captions for chapters 2-6 of the Clark book

Root cause: clean-up found paragraph breaks from the layout indent of pdftotext -layout
(columns, relative to the smallest indent in the whole chapter). Chapter 1 happened to
have a clean left margin; later pages are skewed and drift 1-2 columns, drop caps push
the first lines right by varying amounts, and some pages wrap text around a label, so
continuation lines were taken as paragraph starts (a blank line inside a sentence).

Fix (all deterministic, all logged to warnings.log):
- pdftext: read each line's position in points (pdftotext -bbox-layout) and attach it to
  the layout line it belongs to (units() now carries 'geom'; merged/partial lines get none).
- cleanup: a paragraph starts when a line sits >= 4.5 pt right of the line before it, or
  >= 2.5 pt lower than the page's normal line pitch. A line without a position or starting
  in lower case never starts a paragraph. Text wrapped beside a drop cap is not a start.
  Without positions the old indent rule is still used.
- cleanup: drop cap + small caps on ONE layout line ('F     OR MANY YEARS') is repaired like
  the two-line form (chapters 4 and 5).
- pageparts.header_zone: the running header is found even when text follows it without a
  blank line, and the right-hand header (chapter title, page number sometimes misread as
  't5' or 'a') is recognised from the chapter title.
- cleanup: all-capitals lines below the text are captions (removed); an all-capitals line
  above the text is kept as a section heading in title case ('The Deferred Preface');
  'Right: MARK TWIN' style captions removed; '5. JOY' labels set inside a sentence removed.
- cleanup: paragraph starting with a lone quote mark + lower-case word joins the paragraph
  before it; paragraph-leading ' + capital becomes '"' (not elisions such as 'Tis); a bullet
  in place of an opening quote becomes '"'; closing quote with no opening quote, and a '1'
  that is probably the letter I, are logged only.
- cleanup: mid-line 'worth- while' joined only when certain, otherwise logged.
- tests: new _pipeline/tests/test_cleanup.py (36 tests, 27 fail on the previous code);
  test_pipeline.py helper test_piece renamed make_test_piece so pytest stops collecting it.

Chapter 1 text is byte-identical to the approved pilot text.
Re-run for chapters 1-6: cleanup, figures, script, license. Text, script, warnings and
book.json for chapters 2-6 added.
This commit is contained in:
Audiobook Producer
2026-10-17 17:07:52 -05:00
parent ca5592b964
commit 7f0b598441
18 changed files with 2363 additions and 37 deletions
+164 -11
View File
@@ -75,9 +75,10 @@ def smallcaps_lead(text):
def unit_lines(unit, title, heading, first_unit, notes):
"""Body lines of one unit as dicts {text, indent, where}; removed parts are noted."""
raw = [l.rstrip() for l in unit["text"].split("\n")]
geom = unit.get("geom") or [None] * len(raw)
where = "pdf p.%d%s" % (unit["pdf_page"], unit["half"] or "")
drop = set()
z = pageparts.header_zone(raw, title)
z = pageparts.header_zone(raw, title, [heading["title"]] if heading else [])
if z:
drop.update(range(z[0], z[1]))
what = "page number %d" % z[2] if z[2] is not None else "no page number"
@@ -89,6 +90,9 @@ def unit_lines(unit, title, heading, first_unit, notes):
if first_unit and heading:
drop.update(range(0, heading["title_end"]))
notes.append(("heading-removed", where, "chapter heading 'CHAPTER %d / %s' (spoken header is added in the script)" % (heading["number"], heading["title"])))
# last line of running text: the lines after it (all capitals only) are picture captions, not headings
body = [i for i, l in enumerate(raw) if i not in drop and any(c.islower() for c in l)]
last_body = body[-1] if body else -1
out = []
for i, ln in enumerate(raw):
if i in drop or not ln.strip():
@@ -96,17 +100,87 @@ def unit_lines(unit, title, heading, first_unit, notes):
if pageparts.CAPTION_RE.match(ln):
notes.append(("caption-removed", where, "figure caption '%s' (listed in figures/skipped_figures.md)" % ln.strip()))
continue
out.append({"text": ln.strip(), "indent": len(ln) - len(ln.lstrip()), "where": where})
if CAPTION_MIXED_RE.match(ln):
notes.append(("caption-removed", where, "figure caption '%s' (marker not in capitals)" % ln.strip()))
continue
letters = [c for c in ln if c.isalpha()]
if len(letters) >= 3 and not any(c.islower() for c in letters):
if i > last_body:
notes.append(("caption-removed", where, "all-capitals line '%s' below the text (picture caption or label)" % ln.strip()))
continue
spoken = bookio.title_case(" ".join(ln.split()))
notes.append(("heading-kept", where, "all-capitals line above the text kept as a section heading: '%s' -> '%s'" % (ln.strip(), spoken)))
ln = " " * (len(ln) - len(ln.lstrip())) + spoken
g = geom[i] if i < len(geom) else None
out.append({"text": ln.strip(), "indent": len(ln) - len(ln.lstrip()), "where": where,
"unit": (unit["pdf_page"], unit["half"]), "x": g["x"] if g else None, "y": g["y"] if g else None})
return out
INDENT_PT = 4.5 # a paragraph's first line sits at least this many points right of the line before it
GAP_PT = 2.5 # or this many points lower than the page's normal line spacing
SAMELINE_CAP = re.compile(r"^([A-Z])\s{2,}([A-Z]{2,})\b")
def unit_stats(lines):
"""Per page unit: normal line pitch and the local left margin, read from line positions in points."""
stats = {}
for u in {l["unit"] for l in lines}:
mine = [l for l in lines if l["unit"] == u and l["x"] is not None]
gaps = sorted(b["y"] - a["y"] for a, b in zip(mine, mine[1:]) if 8 < b["y"] - a["y"] < 20)
stats[u] = {"pitch": gaps[(len(gaps) - 1) // 2] if gaps else 13.0, "lines": mine}
return stats
def local_margin(stats, ln):
"""Left margin near the first line of a unit: the leftmost of its next few lines (the page may be skewed)."""
near = [l["x"] for l in stats[ln["unit"]]["lines"] if l is not ln and 0 < l["y"] - ln["y"] < 90]
return min(near) if near else ln["x"]
def starts_paragraph(prev, ln, stats):
"""True if the line opens a paragraph. Evidence is geometric: indented relative to the line before it, or
set lower than normal line spacing. A line with no position, or one that begins in lower case, never starts one."""
if ln["x"] is None or ln["text"][:1].islower():
return False
if prev is not None and prev["unit"] == ln["unit"] and prev["x"] is not None:
return ln["x"] - prev["x"] >= INDENT_PT or ln["y"] - prev["y"] >= stats[ln["unit"]]["pitch"] + GAP_PT
return ln["x"] - local_margin(stats, ln) >= INDENT_PT
def paragraphs_from(lines, inline, notes):
"""Group lines into paragraphs by first-line indent, repair drop caps and hyphenation."""
"""Group lines into paragraphs and repair drop caps. With line positions (the normal case) a paragraph break is
an indent or a vertical gap; without them (older callers) it is the layout indent of the first line."""
geometric = any(l.get("x") is not None for l in lines)
stats = unit_stats(lines) if geometric else None
base = min(l["indent"] for l in lines)
i, paras, cur, cap_indent = 0, [], None, None
i, paras, cur, cap_indent, wrap, prev = 0, [], None, None, False, None
while i < len(lines):
ln = dict(lines[i])
is_cap = split_dropcap(lines, i)
same = SAMELINE_CAP.match(ln["text"]) if not is_cap else None
if geometric:
if wrap and ln["x"] is not None and ln["x"] - local_margin(stats, ln) >= INDENT_PT and not same:
is_start = False # still beside the drop cap
else:
wrap = False
is_start = cur is None or starts_paragraph(prev, ln, stats)
else:
is_start = ln["indent"] - base >= 1 and not (cap_indent is not None and ln["indent"] == cap_indent)
if same and (cur is None or is_start):
letter, rest = same.group(1), ln["text"][same.end(1):].strip()
first = same.group(2)
if known(letter + first) or not (letter in "AI" and known(first)):
merged, how = letter + rest, "joined"
else:
merged, how = letter + " " + rest, "kept as its own word"
notes.append(("dropcap", ln["where"], "drop cap '%s' + '%s' on one line: %s" % (letter, rest[:12], how)))
ln.update(text=merged, indent=base + 1, smallcaps=True)
cur = [ln]
paras.append(cur)
wrap, prev = True, lines[i]
i += 1
continue
if is_cap:
letter, frag = ln["text"], lines[i + 1]
first = re.match(r"[A-Z]+", frag["text"]).group(0)
@@ -117,20 +191,21 @@ def paragraphs_from(lines, inline, notes):
merged = letter + " " + frag["text"]
how = "kept as its own word"
notes.append(("dropcap", ln["where"], "drop cap '%s' + '%s': %s" % (letter, frag["text"][:12], how)))
ln = {"text": merged, "indent": base + 1, "where": ln["where"], "smallcaps": True}
ln = dict(frag, text=merged, indent=base + 1, where=ln["where"], smallcaps=True)
cap_indent = frag["indent"]
i += 2
cur = [ln]
paras.append(cur)
wrap, prev = True, frag
continue
starts = ln["indent"] - base >= 1 and not (cap_indent is not None and ln["indent"] == cap_indent)
if ln["indent"] != cap_indent:
cap_indent = None
if starts or cur is None:
if is_start or cur is None:
cur = [ln]
paras.append(cur)
else:
cur.append(ln)
prev = lines[i]
i += 1
return paras
@@ -151,11 +226,83 @@ def join_lines(para, inline, notes):
return re.sub(r"\s+", " ", text).strip()
ELISION = {"Tis", "Twas", "Twere", "Twill", "Twould"}
CAPTION_MIXED_RE = re.compile(r"^\s*(?:Left|Right|Above|Below|Opposite|Facing)\s*:\s*[A-Z][A-Z .,'\-]+\s*$")
MIDLINE_HYPHEN_RE = re.compile(r"\b([A-Za-z]+)- ([a-z]+)\b")
LABEL_RE = re.compile(r"\s\d\.\s+[A-Z]{3,}(?:\s+[A-Z]{3,})*(?=\s+[a-z])")
DIGIT_ONE_RE = re.compile(r"(?<![\d.,$#/-])\b1 (?=[a-z]+\b)")
def strip_inline_labels(text, where, notes):
"""The book sets '5. JOY' style labels into the middle of a sentence (a typeset inset, not words of the sentence).
Left in, they would be read aloud inside the sentence, so they are removed and logged."""
def drop(m):
notes.append(("inline-label-removed", where, "typeset label '%s' sat inside a sentence" % m.group(0).strip()))
return ""
return LABEL_RE.sub(drop, text)
def fix_midline_hyphens(text, where, notes, inline):
"""'worth- while' inside a line: the scan kept a line-end hyphen after the line was re-flowed.
Joined only when the answer is certain (the hyphenated form occurs elsewhere in the book, or the joined
word is a dictionary word). Otherwise logged and left as it is."""
def fix(m):
joined, reason, ok = join_hyphen(m.group(1), m.group(2), inline)
certain = ok and (reason.startswith("hyphenated form") or reason.startswith("joined word"))
notes.append(("hyphen-joined" if certain else "hyphen-unresolved", where,
"mid-line '%s- %s' -> %s (%s)" % (m.group(1), m.group(2), "'%s'" % joined if certain else "not changed", reason)))
return joined if certain else m.group(0)
return MIDLINE_HYPHEN_RE.sub(fix, text)
def merge_orphan_quotes(paras, chapter, notes):
"""[(text, where)] -> same, where a paragraph that opens with a lone quote mark and a lower-case word is
joined to the paragraph before it. No paragraph starts like that: the page broke the line after an opening
quoted word, as in: They "milked' / " every operation. The stray ' closing the quoted word is made a '"'."""
out = []
for text, where in paras:
if out and re.match(r'"\s+[a-z]', text):
prev, prev_where = out[-1]
fixed = prev
if prev.count('"') % 2 == 1 and re.search(r"[A-Za-z]'$", prev):
fixed = prev[:-1] + '"'
out[-1] = (fixed + " " + text[1:].strip(), prev_where)
notes.append(("paragraph-joined", where, "paragraph began with a lone quote mark and a lower-case word, "
"so it continues the paragraph before it ('...%s' + '%s...')" % (prev[-14:], text[:14])))
else:
out.append((text, where))
return out
def fix_quotes(text, where, notes):
"""A paragraph with an unclosed double quote that ends in punctuation + ' : the ' is a misread closing quote."""
"""Quote marks the scan got wrong. Fixed only when the rule is certain; otherwise the paragraph is logged.
- an unclosed double quote that ends in punctuation + ' : the ' is a misread closing quote.
- a paragraph that opens with ' + capital letter (not an elision such as 'Tis): a misread opening
double quote, as at the start of each paragraph of a long speech.
- a closing double quote (after punctuation) with no opening quote before it in the paragraph is logged only."""
if text.count('"') % 2 == 1 and re.search(r"[.?!]'$", text):
notes.append(("quote-fixed", where, "closing quote misread as apostrophe at '...%s'" % text[-12:]))
return text[:-1] + '"'
text = text[:-1] + '"'
if re.match(r"\u2022[A-Z]", text):
notes.append(("quote-fixed", where, "opening quote misread as a bullet at '%s...'" % text[:16]))
text = '"' + text[1:]
m = re.match(r"'([A-Z][a-z]*)", text)
if m and m.group(1) not in ELISION:
notes.append(("quote-fixed", where, "opening quote misread as apostrophe at '%s...'" % text[:16]))
text = '"' + text[1:]
opened = False
for q in re.finditer('"', text):
before = text[q.start() - 1] if q.start() else ""
if before in ("", " ", "(", "\u2014"):
opened = True
elif before == "'":
opened = not opened
elif opened:
opened = False
else:
notes.append(("quote-unmatched", where, "closing quote with no opening quote before it: '...%s' (not changed)" % text[max(0, q.start() - 14):q.start() + 1]))
for m in DIGIT_ONE_RE.finditer(text):
notes.append(("digit-one", where, "'1' before a lowercase word, probably the letter I misread: '...%s' (not changed)" % text[max(0, m.start() - 8):m.end() + 8]))
return text
@@ -180,14 +327,20 @@ def run(book_dir, book, pdf, chapter_numbers):
for ui in range(ch["first_unit"], ch["last_unit"] + 1):
lines += unit_lines(units[ui], book["title"], heads[ch["number"]], ui == ch["first_unit"], notes)
paras = paragraphs_from(lines, inline, notes)
texts = []
for n, p in enumerate(paras, start=1):
joined = []
for p in paras:
t = join_lines(p, inline, notes)
if p[0].get("smallcaps"):
before, t = t, smallcaps_lead(t)
if before != t:
notes.append(("smallcaps", p[0]["where"], "'%s...' -> '%s...'" % (before[:14], t[:14])))
joined.append((t, p[0]["where"]))
joined = merge_orphan_quotes(joined, ch["number"], notes)
texts = []
for n, (t, _) in enumerate(joined, start=1):
where = "ch%d para %d" % (ch["number"], n)
t = strip_inline_labels(t, where, notes)
t = fix_midline_hyphens(t, where, notes, inline)
t = fix_quotes(t, where, notes)
odd_words(t, where, notes)
texts.append(t)
+19 -12
View File
@@ -16,20 +16,28 @@ def similar(a, b):
return SequenceMatcher(None, norm(a), norm(b)).ratio()
def header_zone(lines, title):
def _mostly_upper(text):
letters = [c for c in text if c.isalpha()]
return (not letters) or sum(c.isupper() for c in letters) / len(letters) > 0.7
def header_zone(lines, title, extra_titles=()):
"""The running header at the top of a unit.
Returns (first_line, last_line_exclusive, page_number or None) or None.
A header is the first block of lines (up to a blank line) that either carries a page number
next to uppercase text, or reads as the book title."""
The header starts at the first non-empty line and runs over the following lines while they are
upper case (at most 3 lines in all), whether or not a blank line separates it from the text.
It is a header if it carries a page number next to uppercase text, or reads as the book title or as
the chapter title (extra_titles: the right-hand page repeats the chapter title, and its page number
may be misread by the scan as a letter, so no number is found)."""
i = 0
while i < len(lines) and not lines[i].strip():
i += 1
j = i
while j < len(lines) and lines[j].strip():
if i >= len(lines):
return None
j = i + 1
while j < len(lines) and lines[j].strip() and j - i < 3 and _mostly_upper(lines[j]):
j += 1
zone = lines[i:j]
if not zone:
return None
number, rest = None, []
for ln in zone:
m = _NUM_LEAD.match(ln) or None
@@ -46,12 +54,11 @@ def header_zone(lines, title):
continue
rest.append(ln)
text = " ".join(rest)
letters = [c for c in text if c.isalpha()]
mostly_upper = (not letters) or sum(c.isupper() for c in letters) / len(letters) > 0.7
if number is not None and mostly_upper and len(zone) <= 3:
if number is not None and _mostly_upper(text) and len(zone) <= 3:
return i, j, number
if title and similar(text, title) >= 0.9:
return i, j, None
for t in (title,) + tuple(extra_titles):
if t and similar(text, t) >= 0.9:
return i, j, None
return None
+49 -2
View File
@@ -4,6 +4,7 @@ import re
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from html import unescape
def _run(cmd):
@@ -42,14 +43,60 @@ def chars_per_page(pdf):
return out
_LINE_RE = re.compile(r'<line xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">(.*?)</line>', re.S)
def page_lines(pdf, page):
"""Text lines of one PDF page with their position in points, top to bottom:
[{'x', 'y', 'x2', 'y2', 'text'}]. Coordinates are those of the displayed page (poppler bbox-layout)."""
xml = _run(["pdftotext", "-bbox-layout", "-f", str(page), "-l", str(page), pdf, "-"])
out = []
for m in _LINE_RE.finditer(xml):
words = [unescape(w) for w in re.findall(r">([^<]+)</word>", m.group(5))]
out.append({"x": float(m.group(1)), "y": float(m.group(2)), "x2": float(m.group(3)),
"y2": float(m.group(4)), "text": " ".join(words)})
return sorted(out, key=lambda ln: (ln["y"], ln["x"]))
def _key(text):
return re.sub(r"[^A-Za-z0-9]", "", text)
def align(raw_lines, boxes, window=8):
"""Give each line of a layout text (pdftotext -layout) the position of the same line from page_lines.
Returns a list parallel to raw_lines: the box dict, or None for blank lines and lines that cannot be
matched one to one (merged columns, small-caps headers). A line that only partly matches a box is left
as None: its position would be wrong."""
result = [None] * len(raw_lines)
j = 0
for i, line in enumerate(raw_lines):
k = _key(line)
if not k:
continue
for jj in range(j, min(j + window, len(boxes))):
b = _key(boxes[jj]["text"])
if b == k:
result[i] = boxes[jj]
j = jj + 1
break
if len(b) >= 8 and (k.endswith(b) or k.startswith(b)):
j = jj + 1
break
return result
def units(pdf):
"""Reading units in order: [{'pdf_page', 'half', 'text'}]. Text keeps the layout (indentation, hyphens)."""
"""Reading units in order: [{'pdf_page', 'half', 'text', 'geom'}]. Text keeps the layout (indentation, hyphens).
'geom' is parallel to text.split('\\n'): the position of each line on the page (see align)."""
result = []
for p, (w, h) in enumerate(page_geometry(pdf), start=1):
boxes = page_lines(pdf, p)
for half, x, width in halves(w, h):
text = _run(["pdftotext", "-layout", "-f", str(p), "-l", str(p),
"-x", str(int(x)), "-y", "0", "-W", str(int(width)), "-H", str(int(h)), pdf, "-"])
result.append({"pdf_page": p, "half": half, "text": text.replace("\f", "")})
text = text.replace("\f", "")
mine = [b for b in boxes if x <= b["x"] < x + width] if half else boxes
result.append({"pdf_page": p, "half": half, "text": text, "geom": align(text.split("\n"), mine)})
return result
+303
View File
@@ -0,0 +1,303 @@
"""Tests for the text stages: paragraph detection from line positions, drop caps, headers, captions, quotes, hyphens.
Run: python3 -m unittest discover -s _pipeline/tests -v (from the repo root), or python3 -m pytest _pipeline/tests"""
import os
import re
import sys
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, ".."))
import bookio
import cleanup
import pageparts
import pdftext
import structure
REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
BOOK = os.path.join(REPO, "books", "clark-glenn", "the-man-who-tapped-the-secrets-of-the-universe")
PDF = os.path.join(BOOK, "The Man Who Tapped the Secrets of the Universe - by Glenn Clark (Walter Russell).pdf")
def line(text, x=None, y=None, unit=(1, "L"), indent=0):
return {"text": text, "indent": indent, "where": "t", "unit": unit, "x": x, "y": y}
def page(rows, unit=(1, "L")):
"""rows: [(text, x, y)] -> line dicts of one page unit."""
return [line(t, x, y, unit) for t, x, y in rows]
def paras(lines):
notes = []
out = cleanup.paragraphs_from(lines, set(), notes)
return [[l["text"] for l in p] for p in out], notes
class ParagraphGeometryTests(unittest.TestCase):
def test_indent_starts_a_paragraph_on_a_skewed_page(self):
# The scan drifts left down the page (43.0 -> 38.6) and the first line of a paragraph sits ~9 pt right.
rows = [("First paragraph line one", 43.0, 100), ("line two continues", 41.5, 113), ("and ends here.", 41.5, 126),
("Second paragraph starts", 50.5, 147), ("and continues", 40.1, 160), ("on this line.", 40.1, 173),
("Third paragraph begins", 47.8, 191), ("and ends.", 38.6, 204)]
got, _ = paras(page(rows))
self.assertEqual([len(p) for p in got], [3, 3, 2])
def test_continuation_line_one_column_right_is_not_a_break(self):
# Layout indent of the old rule was 0 then 1: a 1-column wobble (about 2 pt) must not split a paragraph.
rows = [("Para one a", 40.0, 100), ("para one b", 40.0, 113), ("para one c", 42.0, 126), ("para one d", 42.0, 139)]
got, _ = paras(page(rows))
self.assertEqual(len(got), 1)
def test_vertical_gap_starts_a_paragraph_without_indent(self):
rows = [("End of the last paragraph.", 40.0, 100), ("A new paragraph with no indent", 40.5, 100 + 13 + 4.5),
("continues here.", 40.5, 130.5)]
got, _ = paras(page(rows))
self.assertEqual(len(got), 2)
def test_lower_case_line_never_starts_a_paragraph(self):
rows = [("Text line one", 40.0, 100), ("ecstasy. wrapped beside a label", 52.0, 113), ("more text", 40.0, 126)]
got, _ = paras(page(rows))
self.assertEqual(len(got), 1)
def test_line_without_position_is_a_continuation(self):
rows = [("Text line one", 40.0, 100), ("5. JOY qualities merged line", None, None), ("Next line", 40.0, 126)]
got, _ = paras(page(rows))
self.assertEqual(len(got), 1)
def test_first_line_of_next_page_continues_or_starts_by_indent(self):
p1 = page([("one", 40.0, 100), ("two", 40.0, 113), ("three, cut by the page end", 40.0, 126)], (1, "L"))
cont = page([("continues the sentence", 40.5, 100), ("next line", 40.0, 113), ("and another", 40.0, 126)], (1, "R"))
got, _ = paras(p1 + cont)
self.assertEqual(len(got), 1)
new = page([("Starts a paragraph", 49.5, 100), ("next line", 40.0, 113), ("and another", 40.0, 126)], (1, "R"))
got, _ = paras(p1 + new)
self.assertEqual(len(got), 2)
def test_same_line_drop_cap_and_its_wrapped_lines(self):
# 'F OR MANY YEARS' then three lines pushed right by the drop cap, one of them starting with a capital.
rows = [("F OR MANY YEARS he lectured upon", 40.6, 100), ("self-multiplication of the", 68.0, 113),
("Business to the officers", 68.0, 126), ("Machines Corporation, in the effort", 42.0, 139),
("Second paragraph", 51.0, 160), ("goes on.", 40.0, 173)]
notes = []
out = cleanup.paragraphs_from(page(rows), set(), notes)
self.assertEqual(len(out), 2)
self.assertEqual(len(out[0]), 4)
self.assertTrue(out[0][0]["smallcaps"])
self.assertTrue(out[0][0]["text"].startswith("FOR MANY YEARS"))
self.assertEqual(cleanup.smallcaps_lead(out[0][0]["text"]), "For many years he lectured upon")
self.assertEqual(notes[0][0], "dropcap")
def test_drop_cap_on_its_own_line(self):
rows = [("I", 38.2, 90), ("KNEW by the cut of the Van Dyke beard", 60.6, 92), ("an artist. I could tell by the", 56.2, 105),
("eyes that I was talking to", 56.2, 118), ("of speaking revealed in him", 38.2, 131), ("Next paragraph", 47.0, 152)]
got, _ = paras(page(rows))
self.assertEqual(len(got), 2)
self.assertEqual(got[0][0], "I KNEW by the cut of the Van Dyke beard")
def test_old_rule_still_used_without_positions(self):
lines = [line("Alpha", indent=0), line("beta", indent=0), line("Gamma", indent=2), line("delta", indent=0)]
got, _ = paras(lines)
self.assertEqual(len(got), 2)
class AlignTests(unittest.TestCase):
def test_align_matches_body_lines_and_skips_unmatched(self):
raw = ["2 THE MAN WHO TAPPED", "", " In the field of religion I had", "found several such men."]
boxes = [{"text": "2", "x": 1, "y": 1, "x2": 2, "y2": 2}, {"text": "T HE M AN", "x": 9, "y": 1, "x2": 20, "y2": 2},
{"text": "In the field of religion I had", "x": 50.0, "y": 30, "x2": 300, "y2": 40},
{"text": "found several such men.", "x": 41.0, "y": 43, "x2": 300, "y2": 53}]
got = pdftext.align(raw, boxes)
self.assertIsNone(got[0])
self.assertIsNone(got[1])
self.assertEqual(got[2]["x"], 50.0)
self.assertEqual(got[3]["x"], 41.0)
def test_partial_match_gets_no_position(self):
raw = ["5. JOY qualities of your consciousness which we know by"]
boxes = [{"text": "qualities of your consciousness which we know by", "x": 112.0, "y": 378, "x2": 300, "y2": 388}]
self.assertEqual(pdftext.align(raw, boxes), [None])
class HeaderTests(unittest.TestCase):
TITLE = "The Man Who Tapped the Secrets of the Universe"
def test_header_directly_followed_by_text_without_blank_line(self):
lines = [" 12 THE MAN WHO TAPPED THE SECRETS OFTHE UNIVERSE", " These secrets which Nature whispered to him", " have not only given him"]
self.assertEqual(pageparts.header_zone(lines, self.TITLE), (0, 1, 12))
def test_two_line_header(self):
lines = ["2 THE MAN WHO TAPPED THE SECRETS OF THE", " UNIVERSE", "", " In the field"]
self.assertEqual(pageparts.header_zone(lines, self.TITLE), (0, 2, 2))
def test_chapter_title_header_with_misread_page_number(self):
lines = [" WE MEET THE ARTIST t5", "", "", " Music, for example, financed"]
self.assertIsNone(pageparts.header_zone(lines, self.TITLE))
self.assertEqual(pageparts.header_zone(lines, self.TITLE, ["We Meet the Artist"]), (0, 1, None))
lines[0] = " WE MEET THE ARTIST a"
self.assertEqual(pageparts.header_zone(lines, self.TITLE, ["We Meet the Artist"]), (0, 1, None))
def test_body_text_is_not_a_header(self):
lines = [" He was born in Boston, Massachusetts, May 19th, 1871. He at-", "tended a village school until nearly ten, when"]
self.assertIsNone(pageparts.header_zone(lines, self.TITLE, ["We Meet the Artist"]))
class UnitLinesTests(unittest.TestCase):
def unit(self, text):
return {"pdf_page": 9, "half": "R", "text": text, "geom": None}
def run_unit(self, text):
notes = []
out = cleanup.unit_lines(self.unit(text), "Book Title", None, False, notes)
return [l["text"] for l in out], notes
def test_captions_below_the_text_are_removed(self):
text = " Some running text here.\nmore running text.\n\nPAGE 10: FRANKLIN DELANO ROOSEVELT\n BEFORE PEARL HARBOR\n"
got, notes = self.run_unit(text)
self.assertEqual(got, ["Some running text here.", "more running text."])
self.assertEqual([n[0] for n in notes], ["caption-removed", "caption-removed"])
def test_all_caps_heading_above_the_text_is_kept_in_title_case(self):
text = " THE DEFERRED PREFACE\n\n Now that you have finished reading this book,\nyou must begin again.\n"
got, notes = self.run_unit(text)
self.assertEqual(got[0], "The Deferred Preface")
self.assertEqual(notes[0][0], "heading-kept")
def test_caption_marker_not_in_capitals(self):
got, notes = self.run_unit("Text line of the book.\n\nRight: MARK TWIN\n")
self.assertEqual(got, ["Text line of the book."])
self.assertEqual(notes[0][0], "caption-removed")
class QuoteAndHyphenTests(unittest.TestCase):
def test_opening_quote_misread_as_apostrophe_at_paragraph_start(self):
notes = []
out = cleanup.fix_quotes("'The underlying principle of Balance is equality.", "w", notes)
self.assertEqual(out, '"The underlying principle of Balance is equality.')
self.assertEqual(notes[0][0], "quote-fixed")
def test_elision_is_not_an_opening_quote(self):
notes = []
self.assertEqual(cleanup.fix_quotes("'Tis the season.", "w", notes), "'Tis the season.")
self.assertEqual(notes, [])
def test_bullet_misread_as_opening_quote(self):
notes = []
self.assertEqual(cleanup.fix_quotes("\u2022That is exactly what J am planning,\" he replied.", "w", notes),
'"That is exactly what J am planning," he replied.')
def test_closing_quote_without_opening_is_logged_not_changed(self):
notes = []
text = 'When I am in this Studio I feel like Alice in Wonderland," I remarked, when we were together again."'
self.assertEqual(cleanup.fix_quotes(text, "w", notes), text)
self.assertEqual([n[0] for n in notes], ["quote-unmatched", "quote-unmatched"])
def test_balanced_speech_is_silent(self):
notes = []
text = '"Most sculptors make the mistake," he said, "of thinking of eyes as form."'
self.assertEqual(cleanup.fix_quotes(text, "w", notes), text)
self.assertEqual(notes, [])
def test_digit_one_is_logged_not_changed(self):
notes = []
text = '"What do you mean by beauty?" 1 asked.'
self.assertEqual(cleanup.fix_quotes(text, "w", notes), text)
self.assertEqual(notes[0][0], "digit-one")
def test_orphan_quote_paragraph_is_joined_to_the_one_before(self):
notes = []
paras_in = [('They looked at the product. They "milked\'', "a"), ('" every operation for themselves.', "b"), ("Next.", "c")]
out = cleanup.merge_orphan_quotes(paras_in, 3, notes)
self.assertEqual([t for t, _ in out], ['They looked at the product. They "milked" every operation for themselves.', "Next."])
self.assertEqual(notes[0][0], "paragraph-joined")
def test_a_paragraph_that_starts_a_quote_normally_is_not_joined(self):
out = cleanup.merge_orphan_quotes([("One.", "a"), ('"Two," he said.', "b"), ('" Three, capital', "c")], 1, [])
self.assertEqual(len(out), 3)
def test_inline_label_removed_only_inside_a_sentence(self):
notes = []
text = "No one can multiply himself by 1. HUMILITY himself. He must divide himself and use it in 4. DEEP PURPOSE the direction."
self.assertEqual(cleanup.strip_inline_labels(text, "w", notes),
"No one can multiply himself by himself. He must divide himself and use it in the direction.")
self.assertEqual(len(notes), 2)
keep = "In 1946 he had 3. Some sentence."
self.assertEqual(cleanup.strip_inline_labels(keep, "w", []), keep)
def test_midline_hyphen_joined_only_when_certain(self):
notes = []
inline = {"thought-world"}
out = cleanup.fix_midline_hyphens("only worth- while, the thought- world, and superficially- minded", "w", notes, inline)
self.assertEqual(out, "only worthwhile, the thought-world, and superficially- minded")
self.assertEqual([n[0] for n in notes], ["hyphen-joined", "hyphen-joined", "hyphen-unresolved"])
def test_line_end_hyphen_is_joined_inside_a_paragraph(self):
para = [{"text": "I feel like Alice in Won-", "where": "t"}, {"text": 'derland," I remarked.', "where": "t"}]
self.assertEqual(cleanup.join_lines(para, set(), []), 'I feel like Alice in Wonderland," I remarked.')
@unittest.skipUnless(os.path.exists(PDF), "Clark PDF not in this checkout")
class ClarkBookTests(unittest.TestCase):
"""Runs the real clean-up on chapters 1-6 and checks the numbers that were wrong before."""
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
book = bookio.read_book(BOOK)
cls.book = book
cls.notes = cleanup.run(cls.tmp.name, book, PDF, [1, 2, 3, 4, 5, 6])
cls.text = {}
for n in range(1, 7):
with open(os.path.join(cls.tmp.name, "text", "chapter-%02d.txt" % n), encoding="utf-8") as f:
cls.text[n] = f.read()
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
def paragraphs(self, n):
return [p.strip() for p in self.text[n].split("\n\n") if p.strip()]
def test_chapter_one_is_unchanged(self):
with open(os.path.join(BOOK, "text", "chapter-01.txt"), encoding="utf-8") as f:
self.assertEqual(self.text[1], f.read())
def test_no_paragraph_break_inside_a_sentence(self):
for n in range(1, 7):
for p in self.paragraphs(n):
self.assertNotIn("\n", p)
for a, b in zip(self.paragraphs(n), self.paragraphs(n)[1:]):
self.assertFalse(re.search(r"[A-Za-z,]$", a) and re.match(r"[a-z]", b), (n, a[-30:], b[:30]))
def test_paragraphs_are_long_enough(self):
for n in range(1, 7):
words = [len(p.split()) for p in self.paragraphs(n)]
self.assertGreater(sum(words) / len(words), 40, "chapter %d" % n)
def test_no_line_ends_in_a_hyphen(self):
for n in range(1, 7):
self.assertFalse([p for p in self.paragraphs(n) if p.endswith("-")], "chapter %d" % n)
left = len(re.findall(r"[A-Za-z]- [a-z]", "".join(self.text.values())))
logged = len([k for k, _, m in self.notes if k == "hyphen-unresolved" and m.startswith("mid-line")])
self.assertEqual(left, logged) # the only 'word- word' left are the ones logged as unresolved
def test_drop_caps_are_repaired(self):
self.assertTrue(self.text[4].startswith("For many years he lectured"))
self.assertTrue(self.text[5].startswith("When I am in this Studio"))
for n in range(1, 7):
for p in self.paragraphs(n):
self.assertNotRegex(p[:12], r"^[A-Z] [A-Z]{2,}")
def test_no_running_headers_page_numbers_or_captions(self):
for n in range(1, 7):
self.assertNotRegex(self.text[n], r"THE MAN WHO|WE MEET THE|FIVE LAWS OF|VISION FROM THE|PAGE ?\d+:|Right: |LEFT:|\u2022")
def test_every_change_is_logged(self):
kinds = {k for k, _, _ in self.notes}
for k in ("dropcap", "smallcaps", "hyphen-joined", "header-removed", "caption-removed", "quote-fixed", "paragraph-joined"):
self.assertIn(k, kinds)
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -20,7 +20,7 @@ import voice
CFG = settings.load()
def test_piece(path, hz, seconds, level):
def make_test_piece(path, hz, seconds, level):
"""TEST signal: speech-like (harmonics, 4 Hz syllable rhythm, a little noise) at amplitude `level`."""
expr = "%g*(0.6*sin(2*PI*%g*t)+0.3*sin(4*PI*%g*t)+0.1*sin(6*PI*%g*t))*(0.55+0.45*sin(2*PI*4*t))" % (level, hz, hz, hz)
am.ffmpeg(["-f", "lavfi", "-i", "aevalsrc='%s':s=24000:d=%g" % (expr, seconds), "-ar", "24000", "-ac", "1", path])
@@ -32,7 +32,7 @@ class AudioTests(unittest.TestCase):
items = []
for i, (hz, lvl) in enumerate([(120, 0.02), (150, 0.05), (130, 0.03)]):
p = os.path.join(d, "t%d.wav" % i)
test_piece(p, hz, 4, lvl)
make_test_piece(p, hz, 4, lvl)
items.append({"wav": p, "gap_after_s": 0.7})
if i == 0:
items.append({"cue": "chime", "gap_after_s": 0.25})
@@ -46,7 +46,7 @@ class AudioTests(unittest.TestCase):
def test_checker_fails_bad_files(self):
with tempfile.TemporaryDirectory() as d:
raw = os.path.join(d, "raw.wav")
test_piece(raw, 130, 4, 1.4) # hot test signal, clips at full scale
make_test_piece(raw, 130, 4, 1.4) # hot test signal, clips at full scale
bad = os.path.join(d, "bad.mp3") # stereo, 128k, 22.05 kHz, no room tone
am.ffmpeg(["-i", raw, "-ac", "2", "-ar", "22050", "-c:a", "libmp3lame", "-b:a", "128k", bad])
failed = {n for n, v, p in acx_check.judge(CFG, bad, acx_check.measure(CFG, bad)) if not p}
@@ -70,7 +70,7 @@ class StubHandler(http.server.BaseHTTPRequestHandler):
if self.path.endswith("/audio/speech") or self.path == "/tts":
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "x.wav")
test_piece(p, 130, 1, 0.1)
make_test_piece(p, 130, 1, 0.1)
data = open(p, "rb").read()
self.send_response(200)
self.send_header("Content-Type", "audio/wav")