486 lines
16 KiB
Python
Executable File
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('&', '&')
|
|
.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'<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()
|