diff --git a/build/build.py b/build/build.py
index 6d569e0..21721ca 100755
--- a/build/build.py
+++ b/build/build.py
@@ -1,134 +1,485 @@
#!/usr/bin/env python3
"""
-The Testament — Book Compilation Pipeline
+THE TESTAMENT — Build System
-Compiles all chapters into a single manuscript and generates:
- - PDF (print-ready)
- - EPUB (e-reader)
-
-Requirements:
- - pandoc (brew install pandoc / apt install pandoc)
- - TeX Live or similar for PDF (brew install --cask mactex / apt install texlive-full)
+Compiles the complete novel into distributable formats:
+ 1. Combined markdown (testament-complete.md)
+ 2. EPUB (the-testament.epub)
+ 3. PDF via xelatex (the-testament.pdf)
Usage:
- python3 build/build.py # Build all formats
- python3 build/build.py --pdf # PDF only
- python3 build/build.py --epub # EPUB only
- python3 build/build.py --md # Combined markdown only
+ python3 build/build.py # all formats
+ python3 build/build.py --md # markdown only
+ python3 build/build.py --epub # EPUB only
+ python3 build/build.py --pdf # PDF (xelatex or weasyprint fallback)
+ python3 build/build.py --html # standalone HTML book
+
+Requirements:
+ - pandoc (brew install pandoc)
+ - xelatex (install MacTeX or TinyTeX) — for PDF
"""
-import subprocess
-import sys
import os
+import re
+import sys
+import subprocess
+import shutil
from pathlib import Path
-ROOT = Path(__file__).parent.parent
-BUILD = ROOT / "build"
-CHAPTERS_DIR = ROOT / "chapters"
+# Paths relative to repo root
+REPO = Path(__file__).resolve().parent.parent
+BUILD = REPO / "build"
OUTPUT_DIR = BUILD / "output"
+CHAPTERS_DIR = REPO / "chapters"
+FRONT_MATTER = BUILD / "frontmatter.md"
+BACK_MATTER = BUILD / "backmatter.md"
+METADATA = BUILD / "metadata.yaml"
+STYLESHEET = REPO / "book-style.css"
+COVER_IMAGE = REPO / "cover" / "cover-art.jpg"
-def find_chapters():
- """Find all chapter files, sorted by number."""
- chapters = sorted(CHAPTERS_DIR.glob("chapter-*.md"))
- if not chapters:
- print("ERROR: No chapter files found in", CHAPTERS_DIR)
- sys.exit(1)
- return chapters
+# Output files
+OUT_MD = REPO / "testament-complete.md"
+OUT_EPUB = OUTPUT_DIR / "the-testament.epub"
+OUT_PDF = OUTPUT_DIR / "the-testament.pdf"
-def combine_markdown(chapters):
- """Combine all parts into a single markdown file."""
+# Part divisions
+PARTS = {
+ 1: ("THE BRIDGE", "The bridge. The cabin. The first men. Where despair meets purpose."),
+ 6: ("THE TOWER", "The tower grows. Timmy awakens. Stone breaks. The house appears."),
+ 11: ("THE LIGHT", "Thomas at the door. The network. The story breaks. The green light."),
+}
+
+
+def get_chapter_num(filename):
+ m = re.search(r'chapter-(\d+)', filename)
+ return int(m.group(1)) if m else 0
+
+
+def compile_markdown():
+ """Combine front matter + 18 chapters + back matter into one markdown file."""
parts = []
# Front matter
- front = BUILD / "frontmatter.md"
- if front.exists():
- parts.append(front.read_text())
+ parts.append(FRONT_MATTER.read_text())
# Chapters
- for ch in chapters:
- parts.append(ch.read_text())
+ chapters = sorted(
+ [(get_chapter_num(f), f) for f in os.listdir(CHAPTERS_DIR)
+ if f.startswith("chapter-") and f.endswith(".md")]
+ )
+
+ current_part = 0
+ for num, filename in chapters:
+ if num in PARTS:
+ current_part += 1
+ name, desc = PARTS[num]
+ parts.append(f"\n---\n\n# PART {current_part}: {name}\n\n*{desc}*\n\n---\n")
+
+ content = (CHAPTERS_DIR / filename).read_text()
+ lines = content.split('\n')
+ body = '\n'.join(lines[1:]).strip()
+ parts.append(f"\n{lines[0]}\n\n{body}\n")
# Back matter
- back = BUILD / "backmatter.md"
- if back.exists():
- parts.append(back.read_text())
+ parts.append("\n---\n")
+ parts.append(BACK_MATTER.read_text())
- combined = "\n\n\newpage\n\n".join(parts)
- output = BUILD / "the-testament-full.md"
- output.write_text(combined)
- print(f"Combined markdown: {output} ({len(combined)} chars)")
- return output
+ compiled = '\n'.join(parts)
+ OUT_MD.write_text(compiled)
-def build_pdf(md_file):
- """Build PDF using pandoc + LaTeX."""
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- output = OUTPUT_DIR / "the-testament.pdf"
- metadata = BUILD / "metadata.yaml"
-
- cmd = [
- "pandoc",
- str(md_file),
- "-o", str(output),
- "--metadata-file", str(metadata),
- "--pdf-engine=xelatex",
- "--highlight-style=tango",
- "-V", "colorlinks=true",
- "-V", "linkcolor=blue",
- "-V", "urlcolor=blue",
- ]
-
- print("Building PDF...")
- result = subprocess.run(cmd, capture_output=True, text=True)
- if result.returncode != 0:
- print(f"PDF build failed:\n{result.stderr}")
- return False
- print(f"PDF: {output} ({output.stat().st_size / 1024:.0f} KB)")
+ words = len(compiled.split())
+ size = OUT_MD.stat().st_size
+ print(f" Markdown: {OUT_MD.name} ({words:,} words, {size:,} bytes)")
return True
-def build_epub(md_file):
- """Build EPUB using pandoc."""
+
+def compile_epub():
+ """Generate EPUB via pandoc."""
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- output = OUTPUT_DIR / "the-testament.epub"
- metadata = BUILD / "metadata.yaml"
cmd = [
- "pandoc",
- str(md_file),
- "-o", str(output),
- "--metadata-file", str(metadata),
- "--toc",
- "--epub-chapter-level=1",
+ "pandoc", str(OUT_MD),
+ "-o", str(OUT_EPUB),
+ "--toc", "--toc-depth=2",
+ "--metadata", "title=The Testament",
+ "--metadata", "author=Alexander Whitestone with Timmy",
+ "--metadata", "lang=en",
+ "--metadata", "date=2026",
]
- print("Building EPUB...")
- result = subprocess.run(cmd, capture_output=True, text=True)
- if result.returncode != 0:
- print(f"EPUB build failed:\n{result.stderr}")
+ if METADATA.exists():
+ cmd.extend(["--metadata-file", str(METADATA)])
+ if STYLESHEET.exists():
+ cmd.extend(["--css", str(STYLESHEET)])
+ if COVER_IMAGE.exists():
+ cmd.extend(["--epub-cover-image", str(COVER_IMAGE)])
+
+ r = subprocess.run(cmd, capture_output=True, text=True)
+ if r.returncode == 0:
+ size = OUT_EPUB.stat().st_size
+ print(f" EPUB: {OUT_EPUB.name} ({size:,} bytes, {size/1024:.0f} KB)")
+ return True
+ else:
+ print(f" EPUB FAILED: {r.stderr[:200]}")
return False
- print(f"EPUB: {output} ({output.stat().st_size / 1024:.0f} KB)")
- return True
+
+
+def compile_pdf():
+ """Generate PDF via pandoc + xelatex, or weasyprint fallback."""
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ # Try xelatex first (best quality)
+ if shutil.which("xelatex"):
+ cmd = [
+ "pandoc", str(OUT_MD),
+ "-o", str(OUT_PDF),
+ "--pdf-engine=xelatex",
+ "--toc", "--toc-depth=2",
+ ]
+ if METADATA.exists():
+ cmd.extend(["--metadata-file", str(METADATA)])
+ print(" Building PDF (xelatex)... this takes a minute")
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
+ if r.returncode == 0:
+ size = OUT_PDF.stat().st_size
+ print(f" PDF: {OUT_PDF.name} ({size:,} bytes, {size/(1024*1024):.1f} MB)")
+ return True
+ else:
+ print(f" PDF (xelatex) FAILED: {r.stderr[:300]}")
+
+ # Fallback: pandoc HTML + weasyprint
+ try:
+ import weasyprint
+ html_tmp = OUTPUT_DIR / "the-testament-print.html"
+ cmd = [
+ "pandoc", str(OUT_MD),
+ "-o", str(html_tmp),
+ "--standalone",
+ "--toc", "--toc-depth=2",
+ "--css", str(STYLESHEET),
+ "--metadata", "title=The Testament",
+ ]
+ if METADATA.exists():
+ cmd.extend(["--metadata-file", str(METADATA)])
+ print(" Building PDF (weasyprint)...")
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+ if r.returncode != 0:
+ print(f" PDF (pandoc HTML) FAILED: {r.stderr[:200]}")
+ return False
+
+ doc = weasyprint.HTML(filename=str(html_tmp))
+ doc.write_pdf(str(OUT_PDF))
+ html_tmp.unlink(missing_ok=True)
+ size = OUT_PDF.stat().st_size
+ print(f" PDF: {OUT_PDF.name} ({size:,} bytes, {size/(1024*1024):.1f} MB)")
+ return True
+ except Exception as e:
+ print(f" PDF FAILED: {e}")
+
+ # Fallback 2: reportlab (pure Python, no system deps)
+ return _compile_pdf_reportlab()
+
+
+def _compile_pdf_reportlab():
+ """Generate PDF using reportlab — pure Python, no external dependencies."""
+ try:
+ from reportlab.lib.pagesizes import letter
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+ from reportlab.lib.units import inch
+ from reportlab.lib.colors import HexColor
+ from reportlab.platypus import (
+ SimpleDocTemplate, Paragraph, Spacer, PageBreak,
+ Image as RLImage, Table, TableStyle, HRFlowable
+ )
+ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
+ import io
+ try:
+ import qrcode
+ HAS_QRCODE = True
+ except ImportError:
+ HAS_QRCODE = False
+ except ImportError:
+ print(" PDF SKIPPED: no PDF engine found (install MacTeX, fix weasyprint, or pip install reportlab)")
+ return False
+
+ print(" Building PDF (reportlab)...")
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ styles = getSampleStyleSheet()
+ styles.add(ParagraphStyle(
+ 'BookTitle', parent=styles['Title'],
+ fontSize=28, leading=34, spaceAfter=20,
+ textColor=HexColor('#1a1a2e'), alignment=TA_CENTER
+ ))
+ styles.add(ParagraphStyle(
+ 'BookAuthor', parent=styles['Normal'],
+ fontSize=14, leading=18, spaceAfter=40,
+ textColor=HexColor('#555555'), alignment=TA_CENTER
+ ))
+ styles.add(ParagraphStyle(
+ 'PartTitle', parent=styles['Heading1'],
+ fontSize=22, leading=28, spaceBefore=40, spaceAfter=12,
+ textColor=HexColor('#16213e'), alignment=TA_CENTER
+ ))
+ styles.add(ParagraphStyle(
+ 'PartDesc', parent=styles['Normal'],
+ fontSize=11, leading=15, spaceAfter=30,
+ textColor=HexColor('#666666'), alignment=TA_CENTER, italics=1
+ ))
+ styles.add(ParagraphStyle(
+ 'ChapterTitle', parent=styles['Heading1'],
+ fontSize=20, leading=26, spaceBefore=30, spaceAfter=16,
+ textColor=HexColor('#1a1a2e'), alignment=TA_CENTER
+ ))
+ styles.add(ParagraphStyle(
+ 'BodyText2', parent=styles['Normal'],
+ fontSize=11, leading=16, spaceAfter=8,
+ alignment=TA_JUSTIFY, firstLineIndent=24
+ ))
+ styles.add(ParagraphStyle(
+ 'BodyNoIndent', parent=styles['Normal'],
+ fontSize=11, leading=16, spaceAfter=8,
+ alignment=TA_JUSTIFY
+ ))
+ styles.add(ParagraphStyle(
+ 'SectionBreak', parent=styles['Normal'],
+ fontSize=14, leading=18, spaceBefore=20, spaceAfter=20,
+ alignment=TA_CENTER, textColor=HexColor('#999999')
+ ))
+ styles.add(ParagraphStyle(
+ 'Footer', parent=styles['Normal'],
+ fontSize=9, textColor=HexColor('#888888'), alignment=TA_CENTER
+ ))
+
+ def _make_qr(data, size=80):
+ """Generate a QR code image as a reportlab Image flowable."""
+ if not HAS_QRCODE:
+ return None
+ qr = qrcode.QRCode(version=1, box_size=4, border=1)
+ qr.add_data(data)
+ qr.make(fit=True)
+ img = qr.make_image(fill_color="black", back_color="white")
+ buf = io.BytesIO()
+ img.save(buf, format='PNG')
+ buf.seek(0)
+ return RLImage(buf, width=size, height=size)
+
+ def _parse_md_to_flowables(md_text):
+ """Convert markdown text to reportlab flowables."""
+ flowables = []
+ lines = md_text.split('\n')
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ stripped = line.strip()
+
+ # Horizontal rule
+ if stripped in ('---', '***', '___'):
+ flowables.append(HRFlowable(width="60%", thickness=1,
+ spaceAfter=20, spaceBefore=20, color=HexColor('#cccccc')))
+ i += 1
+ continue
+
+ # H1
+ if stripped.startswith('# ') and not stripped.startswith('## '):
+ text = stripped[2:].strip()
+ # Check if it's a part divider or chapter
+ if text.upper().startswith('PART '):
+ flowables.append(PageBreak())
+ flowables.append(Paragraph(text, styles['PartTitle']))
+ elif text.upper().startswith('CHAPTER '):
+ flowables.append(PageBreak())
+ flowables.append(Paragraph(text, styles['ChapterTitle']))
+ elif 'THE TESTAMENT' in text.upper():
+ flowables.append(Spacer(1, 2*inch))
+ flowables.append(Paragraph(text, styles['BookTitle']))
+ else:
+ flowables.append(Spacer(1, 0.3*inch))
+ flowables.append(Paragraph(text, styles['Heading1']))
+ i += 1
+ continue
+
+ # H2
+ if stripped.startswith('## '):
+ text = stripped[3:].strip()
+ flowables.append(Spacer(1, 0.2*inch))
+ flowables.append(Paragraph(text, styles['Heading2']))
+ i += 1
+ continue
+
+ # Italic-only line (part descriptions, epigraphs)
+ if stripped.startswith('*') and stripped.endswith('*') and len(stripped) > 2:
+ text = stripped.strip('*').strip()
+ flowables.append(Paragraph(f'{_escape(text)}', styles['PartDesc']))
+ i += 1
+ continue
+
+ # Empty line
+ if not stripped:
+ i += 1
+ continue
+
+ # Bold text: **text** -> text
+ # Italic text: *text* -> text
+ # Regular paragraph
+ para_text = _md_inline_to_rml(stripped)
+ flowables.append(Paragraph(para_text, styles['BodyText2']))
+ i += 1
+
+ return flowables
+
+ def _escape(text):
+ """Escape XML special characters."""
+ return (text.replace('&', '&')
+ .replace('<', '<')
+ .replace('>', '>'))
+
+ def _md_inline_to_rml(text):
+ """Convert inline markdown to reportlab XML markup."""
+ text = _escape(text)
+ # Bold: **text**
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
+ # Italic: *text*
+ text = re.sub(r'\*(.+?)\*', r'\1', text)
+ return text
+
+ # Build the PDF
+ doc = SimpleDocTemplate(
+ str(OUT_PDF),
+ pagesize=letter,
+ leftMargin=1.0*inch,
+ rightMargin=1.0*inch,
+ topMargin=0.8*inch,
+ bottomMargin=0.8*inch,
+ title="The Testament",
+ author="Alexander Whitestone with Timmy",
+ )
+
+ story = []
+
+ # Read the compiled markdown
+ if not OUT_MD.exists():
+ compile_markdown()
+ md_text = OUT_MD.read_text()
+
+ # Parse into flowables
+ story = _parse_md_to_flowables(md_text)
+
+ # Add QR codes page at the end
+ qr_links = {
+ "Read Online": "https://timmyfoundation.org/the-testament",
+ "The Door (Game)": "https://timmyfoundation.org/the-door",
+ "Soundtrack": "https://timmyfoundation.org/soundtrack",
+ "Source Code": "https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament",
+ }
+
+ if HAS_QRCODE:
+ story.append(PageBreak())
+ story.append(Paragraph("Experience More", styles['PartTitle']))
+ story.append(Spacer(1, 0.3*inch))
+
+ qr_items = []
+ for label, url in qr_links.items():
+ qr_img = _make_qr(url, size=72)
+ if qr_img:
+ cell_content = []
+ cell_content.append(qr_img)
+ cell_content.append(Spacer(1, 6))
+ cell_content.append(Paragraph(f'{label}', styles['Footer']))
+ qr_items.append(cell_content)
+
+ if qr_items:
+ # Arrange QR codes in a 2x2 table
+ rows = []
+ for i in range(0, len(qr_items), 2):
+ row = qr_items[i:i+2]
+ if len(row) == 1:
+ row.append('')
+ rows.append(row)
+ qr_table = Table(rows, colWidths=[2.5*inch, 2.5*inch])
+ qr_table.setStyle(TableStyle([
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ('TOPPADDING', (0, 0), (-1, -1), 12),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 12),
+ ]))
+ story.append(qr_table)
+
+ # Build
+ try:
+ doc.build(story)
+ size = OUT_PDF.stat().st_size
+ print(f" PDF: {OUT_PDF.name} ({size:,} bytes, {size/(1024*1024):.1f} MB)")
+ return True
+ except Exception as e:
+ print(f" PDF (reportlab) FAILED: {e}")
+ return False
+
+
+def compile_html():
+ """Generate a standalone HTML book for the web reader."""
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ OUT_HTML = REPO / "testament.html"
+
+ cmd = [
+ "pandoc", str(OUT_MD),
+ "-o", str(OUT_HTML),
+ "--standalone",
+ "--toc", "--toc-depth=2",
+ "--css", "book-style.css",
+ "--metadata", "title=The Testament",
+ "--variable", "pagetitle=The Testament",
+ ]
+ if METADATA.exists():
+ cmd.extend(["--metadata-file", str(METADATA)])
+
+ r = subprocess.run(cmd, capture_output=True, text=True)
+ if r.returncode == 0:
+ size = OUT_HTML.stat().st_size
+ print(f" HTML: {OUT_HTML.name} ({size:,} bytes, {size/1024:.0f} KB)")
+ return True
+ else:
+ print(f" HTML FAILED: {r.stderr[:200]}")
+ return False
+
def main():
- args = set(sys.argv[1:])
- build_all = not args or "--all" in args
+ args = sys.argv[1:]
+ do_all = not any(a.startswith("--") and a != "--check" for a in args)
+ do_md = "--md" in args or do_all
+ do_epub = "--epub" in args or do_all
+ do_pdf = "--pdf" in args or do_all
+ do_html = "--html" in args or do_all
- chapters = find_chapters()
- print(f"Found {len(chapters)} chapters")
+ print("=" * 50)
+ print(" THE TESTAMENT — Build System")
+ print("=" * 50)
- md_file = combine_markdown(chapters)
+ # Step 1: Always compile markdown first
+ if do_md or do_epub or do_pdf or do_html:
+ compile_markdown()
- if build_all or "--md" in args:
- print("Markdown combined successfully.")
+ # Step 2: EPUB
+ if do_epub:
+ compile_epub()
- if build_all or "--pdf" in args:
- if not build_pdf(md_file):
- print("PDF build failed (pandoc/LaTeX may not be installed). Skipping.")
+ # Step 3: PDF
+ if do_pdf:
+ compile_pdf()
- if build_all or "--epub" in args:
- if not build_epub(md_file):
- print("EPUB build failed (pandoc may not be installed). Skipping.")
+ # Step 4: Standalone HTML
+ if do_html:
+ compile_html()
+
+ print("=" * 50)
+ print(" Build complete.")
+ print("=" * 50)
+
+ OUT_HTML = REPO / "testament.html"
+ for f in [OUT_MD, OUT_EPUB, OUT_PDF, OUT_HTML]:
+ if f.exists():
+ print(f" ✓ {f.relative_to(REPO)}")
- print("\nDone.")
if __name__ == "__main__":
main()