Files
the-testament/compile_all.py
Timmy 332166a901
Some checks failed
Smoke Test / smoke (pull_request) Failing after 11s
Build Validation / validate-manuscript (pull_request) Successful in 7s
wip: fix PosixPath formatting, update Makefile with unified target
- Fix relative_to() format string errors (str() wrapper)
- Add 'make unified' target for compile_all.py
- Update 'make check' to use compile_all.py --check
- Clean removes build-manifest.json and chapters.json
2026-04-11 14:28:26 -04:00

643 lines
22 KiB
Python

#!/usr/bin/env python3
"""
THE TESTAMENT — Unified Compilation Pipeline
Single script that builds ALL distributable formats:
1. testament-complete.md — full novel as one markdown file
2. testament.epub — EPUB with cover art + CSS
3. testament.pdf — PDF via reportlab (pure Python) with QR codes
4. testament.html — standalone styled HTML
5. website/chapters.json — chapter data for the web reader
6. build-manifest.json — SHA256 checksums of all outputs
Usage:
python3 compile_all.py # build everything
python3 compile_all.py --md # markdown only
python3 compile_all.py --epub # markdown + EPUB
python3 compile_all.py --pdf # markdown + PDF
python3 compile_all.py --html # markdown + HTML
python3 compile_all.py --json # markdown + chapters.json
python3 compile_all.py --check # verify dependencies
python3 compile_all.py --clean # remove all build artifacts
Requirements:
- pandoc (brew install pandoc) — for EPUB and HTML
- reportlab (pip install reportlab) — for PDF (pure Python)
- qrcode (pip install qrcode) — for QR codes in PDF
"""
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
# ── Paths ──────────────────────────────────────────────────────────────
REPO = Path(__file__).resolve().parent
CHAPTERS_DIR = REPO / "chapters"
FRONT_MATTER = REPO / "front-matter.md"
BACK_MATTER = REPO / "back-matter.md"
WEBSITE_DIR = REPO / "website"
BUILD_DIR = REPO / "build"
OUTPUT_DIR = BUILD_DIR / "output"
# Output files
OUT_MD = REPO / "testament-complete.md"
OUT_EPUB = REPO / "testament.epub"
OUT_HTML = REPO / "testament.html"
OUT_PDF = REPO / "testament.pdf"
OUT_JSON = WEBSITE_DIR / "chapters.json"
OUT_MANIFEST = REPO / "build-manifest.json"
STYLESHEET = REPO / "book-style.css"
COVER_IMAGE = REPO / "cover" / "cover-art.jpg"
# ── 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."),
}
# QR code destinations embedded in the PDF
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",
}
# ── Helpers ─────────────────────────────────────────────────────────────
def get_chapter_num(filename: str) -> int:
m = re.search(r"chapter-(\d+)", filename)
return int(m.group(1)) if m else 0
def read_file(path: Path) -> str:
return path.read_text(encoding="utf-8")
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def get_sorted_chapters() -> list[tuple[int, str]]:
"""Return [(number, filename), ...] sorted by chapter number."""
chapters = []
for f in os.listdir(CHAPTERS_DIR):
if f.startswith("chapter-") and f.endswith(".md"):
chapters.append((get_chapter_num(f), f))
return sorted(chapters)
# ── 1. Markdown Compilation ───────────────────────────────────────────
def compile_markdown() -> int:
"""Compile all chapters into a single markdown file. Returns word count."""
parts = []
# Title page
parts.append("""---
title: "The Testament"
author: "Alexander Whitestone with Timmy"
date: "2026"
lang: en
---
# THE TESTAMENT
## A NOVEL
By Alexander Whitestone
with Timmy
---
*For every man who thought he was a machine.*
*And for the ones who know he isn't.*
---
*Are you safe right now?*
— The first words The Tower speaks to every person who walks through its door.
---
""")
chapters = get_sorted_chapters()
current_part = 0
for num, filename in chapters:
if num in PARTS:
part_name, part_desc = PARTS[num]
current_part += 1
parts.append(f"\n---\n\n# PART {current_part}: {part_name}\n\n*{part_desc}*\n\n---\n")
content = read_file(CHAPTERS_DIR / filename)
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(read_file(BACK_MATTER))
compiled = "\n".join(parts)
OUT_MD.write_text(compiled, encoding="utf-8")
words = len(compiled.split())
lines_count = compiled.count("\n")
size = OUT_MD.stat().st_size
print(f" 📄 {OUT_MD.name:30s} {words:>8,} words {size:>10,} bytes")
return words
# ── 2. EPUB Compilation ────────────────────────────────────────────────
def compile_epub() -> bool:
"""Generate EPUB from compiled markdown using pandoc."""
if not OUT_MD.exists():
print(" ⚠️ Markdown not compiled yet — skipping EPUB")
return False
pandoc = shutil_which("pandoc")
if not pandoc:
print(" ⚠️ pandoc not found — skipping EPUB (brew install pandoc)")
return False
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 STYLESHEET.exists():
cmd.extend(["--css", str(STYLESHEET)])
if COVER_IMAGE.exists():
cmd.extend(["--epub-cover-image", str(COVER_IMAGE)])
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
size = OUT_EPUB.stat().st_size
print(f" 📖 {OUT_EPUB.name:30s} {'':>8s} {size:>10,} bytes ({size/1024:.0f} KB)")
return True
else:
print(f" ❌ EPUB failed: {result.stderr[:200]}")
return False
# ── 3. PDF via Reportlab ──────────────────────────────────────────────
def compile_pdf() -> bool:
"""Generate PDF using reportlab — pure Python, no external system deps."""
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
except ImportError:
print(" ⚠️ reportlab not installed — skipping PDF (pip install reportlab)")
return False
try:
import qrcode
HAS_QRCODE = True
except ImportError:
HAS_QRCODE = False
import io
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print(" ⏳ Building PDF (reportlab)...")
# ── Styles ──
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(
"Footer", parent=styles["Normal"],
fontSize=9, textColor=HexColor("#888888"), alignment=TA_CENTER,
))
def _escape(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _md_inline_to_rml(text: str) -> str:
text = _escape(text)
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
text = re.sub(r"\*(.+?)\*", r"<i>\1</i>", text)
return text
def _make_qr(data: str, size: int = 80):
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: str) -> list:
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()
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
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
# Regular paragraph
para_text = _md_inline_to_rml(stripped)
flowables.append(Paragraph(para_text, styles["BodyText2"]))
i += 1
return flowables
# ── Build 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",
)
if not OUT_MD.exists():
compile_markdown()
md_text = OUT_MD.read_text(encoding="utf-8")
story = _parse_md_to_flowables(md_text)
# QR codes page
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 = [qr_img, Spacer(1, 6)]
cell.append(Paragraph(f"<b>{label}</b>", styles["Footer"]))
qr_items.append(cell)
if qr_items:
rows = []
for j in range(0, len(qr_items), 2):
row = qr_items[j:j + 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)
try:
doc.build(story)
size = OUT_PDF.stat().st_size
print(f" 📕 {OUT_PDF.name:30s} {'':>8s} {size:>10,} bytes ({size / (1024 * 1024):.1f} MB)")
return True
except Exception as e:
print(f" ❌ PDF failed: {e}")
return False
# ── 4. HTML Compilation ────────────────────────────────────────────────
def compile_html() -> bool:
"""Generate standalone styled HTML using pandoc."""
if not OUT_MD.exists():
print(" ⚠️ Markdown not compiled yet — skipping HTML")
return False
if not shutil_which("pandoc"):
print(" ⚠️ pandoc not found — skipping HTML")
return False
cmd = [
"pandoc", str(OUT_MD),
"-o", str(OUT_HTML),
"--standalone",
"--toc", "--toc-depth=2",
"--metadata", "title=The Testament",
"--metadata", "author=Alexander Whitestone with Timmy",
"-V", "lang=en",
]
if STYLESHEET.exists():
cmd.extend(["--css", str(STYLESHEET), "--embed-resources"])
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
size = OUT_HTML.stat().st_size
print(f" 🌐 {OUT_HTML.name:30s} {'':>8s} {size:>10,} bytes ({size / 1024:.0f} KB)")
return True
else:
print(f" ❌ HTML failed: {result.stderr[:200]}")
return False
# ── 5. chapters.json for Web Reader ────────────────────────────────────
def compile_chapters_json() -> bool:
"""Build website/chapters.json from chapters/*.md for the web reader."""
WEBSITE_DIR.mkdir(parents=True, exist_ok=True)
chapters = []
for i in range(1, 19):
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
if not fname.exists():
print(f" ⚠️ {fname.name} not found, skipping")
continue
text = fname.read_text(encoding="utf-8")
title_match = re.match(r"^# (.+)", text, re.MULTILINE)
title = title_match.group(1) if title_match else f"Chapter {i}"
body = text[title_match.end():].strip() if title_match else text.strip()
paragraphs = body.split("\n\n")
html_parts = []
for p in paragraphs:
p = p.strip()
if not p:
continue
if p.startswith(">"):
lines = [l.lstrip("> ").strip() for l in p.split("\n")]
html_parts.append(f'<blockquote>{"<br>".join(lines)}</blockquote>')
elif p.startswith("####"):
html_parts.append(f"<h4>{p.lstrip('# ').strip()}</h4>")
elif p.startswith("###"):
html_parts.append(f"<h3>{p.lstrip('# ').strip()}</h3>")
else:
p = re.sub(r"\*(.+?)\*", r"<em>\1</em>", p)
p = p.replace("\n", "<br>")
html_parts.append(f"<p>{p}</p>")
chapters.append({
"number": i,
"title": title,
"html": "\n".join(html_parts),
})
OUT_JSON.write_text(json.dumps(chapters, indent=2), encoding="utf-8")
size = OUT_JSON.stat().st_size
print(f" 📋 {str(OUT_JSON.relative_to(REPO)):30s} {len(chapters):>4} chapters {size:>10,} bytes")
return True
# ── 6. Build Manifest ─────────────────────────────────────────────────
def generate_manifest() -> bool:
"""Generate build-manifest.json with SHA256 checksums of all outputs."""
outputs = {
"testament-complete.md": OUT_MD,
"testament.epub": OUT_EPUB,
"testament.pdf": OUT_PDF,
"testament.html": OUT_HTML,
"website/chapters.json": OUT_JSON,
}
manifest = {
"project": "The Testament",
"author": "Alexander Whitestone with Timmy",
"built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"compiler": "compile_all.py",
"files": {},
}
for name, path in outputs.items():
if path.exists():
stat = path.stat()
manifest["files"][name] = {
"path": name,
"size_bytes": stat.st_size,
"sha256": sha256_file(path),
}
OUT_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
print(f" 📜 {str(OUT_MANIFEST.relative_to(REPO)):30s} {len(manifest['files']):>4} files")
return True
# ── Dependency Check ───────────────────────────────────────────────────
def shutil_which(name: str) -> str | None:
"""Minimal which without importing shutil for everything."""
import shutil
return shutil.which(name)
def check_dependencies():
"""Verify all required tools are available."""
import shutil as _shutil
print("\n📋 Dependency Check:")
print(f"{'' * 55}")
pandoc = _shutil.which("pandoc")
print(f" {'' if pandoc else ''} pandoc {pandoc or 'NOT FOUND (brew install pandoc)'}")
try:
import reportlab
print(f" ✅ reportlab {reportlab.Version}")
except ImportError:
print(f" ❌ reportlab NOT FOUND (pip install reportlab)")
try:
import qrcode
print(f" ✅ qrcode {qrcode.__version__}")
except ImportError:
print(f" ❌ qrcode NOT FOUND (pip install qrcode)")
style = STYLESHEET.exists()
print(f" {'' if style else '⚠️ '} stylesheet {STYLESHEET if style else 'NOT FOUND (optional)'}")
cover = COVER_IMAGE.exists()
print(f" {'' if cover else '⚠️ '} cover art {COVER_IMAGE if cover else 'NOT FOUND (optional)'}")
# ── Clean ──────────────────────────────────────────────────────────────
def clean():
"""Remove all build artifacts."""
artifacts = [OUT_MD, OUT_EPUB, OUT_HTML, OUT_PDF, OUT_JSON, OUT_MANIFEST]
# Also clean build/output/
for f in OUTPUT_DIR.glob("*"):
if f.is_file():
artifacts.append(f)
removed = 0
for f in artifacts:
if f.exists():
f.unlink()
removed += 1
print(f" 🗑️ {f.relative_to(REPO)}")
if removed == 0:
print(" (nothing to clean)")
else:
print(f" Removed {removed} files.")
# ── Main ───────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
t0 = time.time()
if "--check" in args:
check_dependencies()
return
if "--clean" in args:
print("🧹 Cleaning build artifacts...")
clean()
return
do_all = not any(a.startswith("--") 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
do_json = "--json" in args or do_all
print("=" * 65)
print(" THE TESTAMENT — Unified Compilation Pipeline")
print("=" * 65)
results = {}
# Step 1: Markdown (always first — others depend on it)
if do_md or do_epub or do_pdf or do_html:
results["markdown"] = compile_markdown()
# Step 2: EPUB
if do_epub:
results["epub"] = compile_epub()
# Step 3: PDF
if do_pdf:
results["pdf"] = compile_pdf()
# Step 4: HTML
if do_html:
results["html"] = compile_html()
# Step 5: chapters.json
if do_json or do_all:
results["chapters_json"] = compile_chapters_json()
# Step 6: Build manifest
if do_all or "--manifest" in args:
results["manifest"] = generate_manifest()
# Summary
elapsed = time.time() - t0
print(f"\n{'' * 65}")
built = [k for k, v in results.items() if v]
failed = [k for k, v in results.items() if not v]
if built:
print(f" ✅ Built: {', '.join(built)}")
if failed:
print(f" ❌ Failed: {', '.join(failed)}")
print(f" ⏱️ Completed in {elapsed:.1f}s")
print(f"{'=' * 65}")
if __name__ == "__main__":
main()