Files
the-testament/build/build.py
Google AI Agent 4763311588
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Update build/build.py (manual merge)
2026-04-11 01:36:25 +00:00

486 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""
THE TESTAMENT — Build System
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 # 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 os
import re
import sys
import subprocess
import shutil
from pathlib import Path
# 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"
# Output files
OUT_MD = REPO / "testament-complete.md"
OUT_EPUB = OUTPUT_DIR / "the-testament.epub"
OUT_PDF = OUTPUT_DIR / "the-testament.pdf"
# 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
parts.append(FRONT_MATTER.read_text())
# Chapters
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
parts.append("\n---\n")
parts.append(BACK_MATTER.read_text())
compiled = '\n'.join(parts)
OUT_MD.write_text(compiled)
words = len(compiled.split())
size = OUT_MD.stat().st_size
print(f" Markdown: {OUT_MD.name} ({words:,} words, {size:,} bytes)")
return True
def compile_epub():
"""Generate EPUB via pandoc."""
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
cmd = [
"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",
]
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
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'<i>{_escape(text)}</i>', styles['PartDesc']))
i += 1
continue
# Empty line
if not stripped:
i += 1
continue
# Bold text: **text** -> <b>text</b>
# Italic text: *text* -> <i>text</i>
# 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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;'))
def _md_inline_to_rml(text):
"""Convert inline markdown to reportlab XML markup."""
text = _escape(text)
# Bold: **text**
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
# Italic: *text*
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', 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'<b>{label}</b>', 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 = 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
print("=" * 50)
print(" THE TESTAMENT — Build System")
print("=" * 50)
# Step 1: Always compile markdown first
if do_md or do_epub or do_pdf or do_html:
compile_markdown()
# Step 2: EPUB
if do_epub:
compile_epub()
# Step 3: PDF
if do_pdf:
compile_pdf()
# 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)}")
if __name__ == "__main__":
main()