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
+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