#!/usr/bin/env python3 """Build a small synthetic test book (NOT a real book) to prove the pipeline end to end. Two chapters of about 300 words, title page, copyright page, contents, chapter headings, running headers, page numbers, and one page that is only a picture of text (no text layer), so the OCR path is used. pip install reportlab pillow (this tool only; the pipeline itself does not need them) python3 _pipeline/tools/make_test_pdf.py out.pdf """ import sys import tempfile import os from reportlab.lib.pagesizes import letter from reportlab.lib.utils import simpleSplit, ImageReader from reportlab.pdfgen import canvas from PIL import Image, ImageDraw, ImageFont TITLE = "Pipeline Smoke Test Book" AUTHOR = "Ada Testwell" W, H = letter LEFT, RIGHT, TOP = 72, W - 72, H - 90 CH1 = [ "The harbor was quiet on the morning the keeper first climbed the stairs of the old lantern tower. The fishing boats had gone out before dawn, and only the gulls remained, turning slowly above the water as if they were waiting for someone to speak.", "He had been told that the work was simple. Each evening he was to light the lamp, and each morning he was to put it out. Between those two tasks there was nothing at all, only the sea, the wind, and the long patience of the stone walls.", "On the third day he began to notice small things. The stairs had forty-two steps, and the forty-first was always cold. The window on the north side hummed when the wind came from the east. A single brass key hung beside the door, and nobody could say what it opened.", "He wrote these facts in a notebook, not because anyone would read them, but because a fact written down seemed to belong to him in a way that a fact merely seen did not. By the end of the first week the notebook held thirty pages, and the tower no longer felt like a stranger's house.", "In the evenings the fishermen rowed past and raised their hands. He raised his own in return. It was a small conversation, but it was enough, and it taught him that a person can be useful without being busy, and present without being loud.", "When the lamp was lit for the first time, the whole room turned gold, and he understood at last what the work was for. It was not the light itself, but the promise of the light, spoken every night to people he would never meet.", ] CH2 = [ "The road out of the harbor town climbed through pastures and then through pine woods, and it was long enough that most travelers walked it in silence. The keeper took it one spring when the supply boat failed to come, carrying only a loaf of bread and his notebook.", "At the first crossroads he met a woman driving a cart of apples. She asked where he was going, and he said he did not know yet, only that the road seemed to know. She laughed and gave him an apple, and told him that was the best answer she had heard all year.", "By noon the pines gave way to open hills, and from the highest of them he could see both the sea behind him and a river ahead. He sat for a while and wrote in the notebook that distance is not a number but a feeling, which grows smaller each time you decide to keep going.", "That evening he reached a village where the lamps were already lit in every window. The scanned page of the old village register recorded his name in a careful hand, and beneath it the innkeeper wrote the date, the weather, and the single word welcome.", "He slept soundly for the first time in many weeks. In the morning the innkeeper handed him a small brass key, saying it had been left there years before by another keeper, and that it opened the door of a tower he had not yet seen.", "He walked home by the same road, and it seemed shorter. When the lantern tower came into view, he saw that someone had lit the lamp in his absence, and he knew then that the work had never belonged to him alone.", ] def wrap(c, text, font, size, width): return simpleSplit(text, font, size, width) def draw_paragraphs(c, paras, y, font="Times-Roman", size=11, leading=14, bottom=90): """Draws paragraphs with a first-line indent. Returns (y, leftover paragraphs).""" left = list(paras) while left: lines = wrap(c, left[0], font, size, RIGHT - LEFT - 18) # first line is narrower because of the indent; re-wrap properly first = simpleSplit(left[0], font, size, RIGHT - LEFT - 18)[0] rest = left[0][len(first):].strip() lines = [first] + (simpleSplit(rest, font, size, RIGHT - LEFT) if rest else []) if y - leading * len(lines) < bottom: break c.setFont(font, size) for i, ln in enumerate(lines): c.drawString(LEFT + (18 if i == 0 else 0), y, ln) y -= leading left.pop(0) return y, left def running_header(c, number): c.setFont("Times-Roman", 10) c.drawString(LEFT, H - 50, TITLE.upper()) c.drawRightString(RIGHT, H - 50, str(number)) def scanned_page_image(paras, path): """A picture of typeset text: 200 dpi, serif font, no text layer once placed in the PDF.""" dpi = 200 pw, ph = int((RIGHT - LEFT) / 72 * dpi), int((H - 160) / 72 * dpi) img = Image.new("L", (pw, ph), 255) d = ImageDraw.Draw(img) font = None for name in ("DejaVuSerif.ttf", "LiberationSerif-Regular.ttf", "NotoSerif-Regular.ttf"): for root in ("/usr/share/fonts/truetype/dejavu", "/usr/share/fonts/truetype/liberation", "/usr/share/fonts/truetype/noto"): if os.path.exists(os.path.join(root, name)): font = ImageFont.truetype(os.path.join(root, name), int(11 * dpi / 72)) break if font: break if font is None: font = ImageFont.load_default(size=int(11 * dpi / 72)) y = 20 for p in paras: words, line, first = p.split(), "", True for w in words: trial = (line + " " + w).strip() if d.textlength(trial, font=font) > pw - 20 - (60 if first else 0): d.text((10 + (60 if first else 0), y), line, font=font, fill=0) y += int(font.size * 1.3) line, first = w, False else: line = trial d.text((10 + (60 if first else 0), y), line, font=font, fill=0) y += int(font.size * 1.3) img.convert("1").save(path, dpi=(dpi, dpi)) return pw / dpi * 72, ph / dpi * 72 def build(out): c = canvas.Canvas(out, pagesize=letter) c.setTitle(TITLE) c.setAuthor(AUTHOR) # page 1 title page c.setFont("Times-Bold", 26) c.drawCentredString(W / 2, H - 260, TITLE.upper()) c.setFont("Times-Roman", 14) c.drawCentredString(W / 2, H - 310, "By " + AUTHOR) c.showPage() # page 2 copyright c.setFont("Times-Roman", 11) c.drawString(LEFT, H - 200, "First published in 1925.") c.drawString(LEFT, H - 218, "This is a synthetic book made by code to test an audiobook pipeline.") c.showPage() # page 3 contents c.setFont("Times-Bold", 14) c.drawString(LEFT, H - 120, "CONTENTS") c.setFont("Times-Roman", 12) c.drawString(LEFT, H - 160, "I. The Quiet Harbor " + "." * 40 + " 1") c.drawString(LEFT, H - 180, "II. The Long Road " + "." * 42 + " 3") c.showPage() # chapter 1, book pages 1-2 (pdf pages 4-5) page_no = 1 for chapter_no, name, paras in ((1, "THE QUIET HARBOR", CH1), (2, "THE LONG ROAD", CH2)): first, left = True, list(paras) while left: if first: c.setFont("Times-Bold", 14) c.drawString(LEFT, TOP, "CHAPTER " + ("ONE" if chapter_no == 1 else "TWO")) c.drawString(LEFT, TOP - 20, name) y = TOP - 60 else: running_header(c, page_no) y = TOP - 20 if chapter_no == 2 and not first: break # the rest of chapter 2 is the scanned page y, left = draw_paragraphs(c, left[:3] if chapter_no == 1 and first else left[:3] if chapter_no == 2 else left, y) if chapter_no == 1 and first: # keep paragraphs for the next page left = left + CH1[3:] c.setFont("Times-Roman", 10) c.drawCentredString(W / 2, 50, str(page_no)) c.showPage() page_no += 1 first = False if chapter_no == 1 and not left: break if chapter_no == 1 and len(left) <= len(CH1) - 3 and not first: pass if chapter_no == 2: # scanned page: only a picture of the remaining paragraphs with tempfile.TemporaryDirectory() as tmp: png = os.path.join(tmp, "scan.png") w, h = scanned_page_image(CH2[3:], png) c.drawImage(ImageReader(png), LEFT, 90, width=w, height=h) c.showPage() c.save() if __name__ == "__main__": build(sys.argv[1] if len(sys.argv) > 1 else "pipeline-smoke-test-book.pdf")