"""Stage 3, structure: find chapter boundaries from the text layer. Audible style file list is made by the script stage.""" import re import bookio import pageparts import pdftext WORDS = "ONE TWO THREE FOUR FIVE SIX SEVEN EIGHT NINE TEN ELEVEN TWELVE THIRTEEN FOURTEEN FIFTEEN SIXTEEN SEVENTEEN EIGHTEEN NINETEEN TWENTY".split() ROMAN = {"I": 1, "V": 5, "X": 10, "L": 50} HEADING_RE = re.compile(r"^\s*CHAPTER\b[\s.:]*(.*?)[\s.,:;]*$") def chapter_number(token): if token.isdigit(): return int(token) if token in WORDS: return WORDS.index(token) + 1 if set(token) <= set(ROMAN): total = 0 for a, b in zip(token, token[1:] + " "): total += -ROMAN[a] if b in ROMAN and ROMAN[b] > ROMAN[a] else ROMAN[a] return total return None def heading_number(line, expected): """(number, guessed) for a 'CHAPTER ' line, else None. The number may be a roman numeral, digits or a word. OCR often damages the numeral ('I I r', 'X V', 'X f I'): a short unreadable token after an upper-case CHAPTER is taken to be the next chapter in sequence (guessed=True, and a warning is written by run()).""" m = HEADING_RE.match(line) if not m: return None rest = m.group(1).strip() if re.fullmatch(r"[A-Z]+|\d+", rest): n = chapter_number(rest) if n is not None: return n, False collapsed = re.sub(r"\s+", "", rest) if 0 < len(rest) <= 8 and re.fullmatch(r"[A-Za-z0-9|!.,]+", collapsed): n = chapter_number(collapsed.upper().strip(".,")) if n is not None and not (" " in rest and n != expected): # 'I I' is more likely III than II when III is next return n, False return expected, True return None def find_headings(units): """'CHAPTER ONE' then the title line(s) up to a blank line, within the first 12 lines of a unit. A heading whose number does not rise (a table-of-contents entry that came before the real chapter) is replaced by the later one. With no heading at all the whole body is one chapter ('synthetic').""" found = [] for ui, u in enumerate(units): lines = u["text"].split("\n") shown = 0 for li, ln in enumerate(lines): if ln.strip(): shown += 1 if shown > 12: break got = heading_number(ln, (found[-1]["number"] + 1) if found else 1) if got is None: continue n, guessed = got k = li + 1 while k < len(lines) and not lines[k].strip(): k += 1 title = [] while k < len(lines) and lines[k].strip(): title.append(lines[k].strip()) k += 1 h = {"unit": ui, "line": li, "title_end": k, "number": n, "title": bookio.title_case(" ".join(title))} if guessed: h["guessed"] = True while found and found[-1]["number"] >= n: # the earlier one was a contents entry or a misread found.pop() h["replaced_earlier"] = True found.append(h) break if not found: for ui, u in enumerate(units): if len(re.sub(r"\s", "", u["text"])) >= 500: return [{"unit": ui, "line": -1, "title_end": 0, "number": 1, "title": "The Complete Text", "synthetic": True}] return found def toc_chapters(units, before_unit): """Chapter lines of the table of contents, read from the units before the first chapter: [(roman, title, page)].""" lines = [] for u in units[:before_unit]: lines += u["text"].split("\n") out = [] for i, ln in enumerate(lines): m = re.match(r"^\s*([IVX]+)\.\s+(.+?)\s*\.{3,}\s*(\d+)?\s*$", ln) if not m: continue page = m.group(3) if page is None and i + 1 < len(lines) and re.match(r"^\s*\d+\s*$", lines[i + 1]): page = lines[i + 1].strip() out.append((chapter_number(m.group(1)), bookio.title_case(m.group(2)), int(page) if page else None)) return out def book_pages(units, title): """Printed page number of each unit (None if unknown). Printed numbers are read from headers/footers; a unit without one takes its neighbor's number plus or minus its distance.""" printed = [] for u in units: lines = u["text"].split("\n") z = pageparts.header_zone(lines, title) num = z[2] if z else None if num is None: f = pageparts.footer_number(lines) num = f[1] if f else None printed.append(num) anchors = [i for i, n in enumerate(printed) if n is not None] pages = [] for i in range(len(units)): if printed[i] is not None: pages.append(printed[i]) continue near = min(anchors, key=lambda a: (abs(a - i), a > i)) if anchors else None guess = printed[near] + (i - near) if near is not None else None pages.append(guess if guess and guess >= 1 else None) return pages, printed def run(book_dir, book, pdf): """Fills book['chapters']. Returns warnings.""" units = pdftext.units(pdf) heads = find_headings(units) warnings = [] if not heads: raise SystemExit("no chapter headings found and no page with a body of text to treat as a single chapter") for h in heads: if h.get("synthetic"): warnings.append(("no-chapter-headings", "book", "no 'CHAPTER ' headings found; the text from pdf page %d on is treated as one chapter" % units[h["unit"]]["pdf_page"])) if h.get("guessed"): warnings.append(("chapter-number-guessed", "chapter %d" % h["number"], "the number after CHAPTER on pdf page %d was unreadable (OCR); taken as the next in sequence" % units[h["unit"]]["pdf_page"])) if h.get("replaced_earlier"): warnings.append(("heading-replaced", "chapter %d" % h["number"], "an earlier CHAPTER line with the same or a higher number (a contents entry or misread) was dropped in favour of the heading on pdf page %d" % units[h["unit"]]["pdf_page"])) pages, printed = book_pages(units, book["title"]) toc = {n: (t, p) for n, t, p in toc_chapters(units, heads[0]["unit"])} old = {c["number"]: c for c in book.get("chapters", [])} chapters = [] for k, h in enumerate(heads): if k + 1 < len(heads): nh = heads[k + 1] last_unit = nh["unit"] - 1 lines = units[nh["unit"]]["text"].split("\n") z = pageparts.header_zone(lines, book["title"]) stray = [l for i, l in enumerate(lines[:nh["line"]]) if l.strip() and not (z and z[0] <= i < z[1])] if stray: warnings.append(("chapter-starts-mid-page", "chapter %d" % nh["number"], "text sits above the heading on its page; it is not assigned to the previous chapter")) else: last_unit = len(units) - 1 ch = { "number": h["number"], "title": h["title"], "first_unit": h["unit"], "last_unit": last_unit, "pdf_pages": [units[h["unit"]]["pdf_page"], units[last_unit]["pdf_page"]], "book_pages": [pages[h["unit"]], pages[last_unit]], "switches": old.get(h["number"], {}).get("switches", {}), } if h["number"] in toc: t_title, t_page = toc[h["number"]] ch["toc_page"] = t_page if t_page is not None and t_page != ch["book_pages"][0]: warnings.append(("toc-mismatch", "chapter %d" % h["number"], "table of contents says page %s, structure found page %s" % (t_page, ch["book_pages"][0]))) if pageparts.similar(t_title, h["title"]) < 0.9: warnings.append(("toc-mismatch", "chapter %d" % h["number"], "table of contents title '%s' differs from heading '%s'" % (t_title, h["title"]))) chapters.append(ch) warnings.append(("last-chapter-end", "chapter %d" % chapters[-1]["number"], "no later heading, so the chapter is taken to the end of the book (back matter, if any, is included)")) book["chapters"] = chapters return warnings