Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
dea06a0995 feat: unified compile_all.py — produces MD, EPUB, PDF, HTML, manifest
Some checks failed
Smoke Test / smoke (pull_request) Failing after 6s
Build Validation / validate-manuscript (pull_request) Successful in 6s
- Single script compiles all distributable formats
- Generates build manifest with SHA256 checksums
- Generates chapters.json for web reader
- reportlab fallback for PDF (no system deps needed)
- QR codes linking to soundtrack, game, website, source
- Closes #18
2026-04-11 14:18:14 -04:00
5 changed files with 1107 additions and 208 deletions

50
build/manifest.json Normal file
View File

@@ -0,0 +1,50 @@
{
"project": "The Testament",
"author": "Alexander Whitestone with Timmy",
"version": "1.0.0",
"built_at": "2026-04-11T18:18:09.197952+00:00",
"formats": {
"markdown": {
"path": "/private/tmp/timmy-burn-1775931309/the-testament/testament-complete.md",
"words": 19490,
"lines": 2229,
"bytes": 112408
},
"epub": {
"path": "/private/tmp/timmy-burn-1775931309/the-testament/build/output/the-testament.epub",
"bytes": 69460
},
"pdf": {
"path": "/private/tmp/timmy-burn-1775931309/the-testament/build/output/the-testament.pdf",
"bytes": 143222
},
"html": {
"path": "/private/tmp/timmy-burn-1775931309/the-testament/testament.html",
"bytes": 3866869
}
},
"files": {
"testament-complete.md": {
"path": "testament-complete.md",
"bytes": 112408,
"sha256": "84a0fb4fe9575ab78c839386f31f058881455d0b95e58cf1004ad619e695ba64"
},
"the-testament.epub": {
"path": "build/output/the-testament.epub",
"bytes": 69460,
"sha256": "7d7f497fb1e36a5c35d7beb61613f89cf6eee50bf175d9f7146e6b1943c27033"
},
"the-testament.pdf": {
"path": "build/output/the-testament.pdf",
"bytes": 143222,
"sha256": "c2a41f70667a7f53f49f6a5e934d3d7860030e901a323bebbfc583e5e48db53a"
},
"testament.html": {
"path": "testament.html",
"bytes": 3866869,
"sha256": "e68285f808f8cdb5c785e9295b2a5b689b34d40d252f85dec07c6a5701de63e8"
}
},
"chapters": 18,
"words": 19490
}

Binary file not shown.

582
compile_all.py Normal file
View File

@@ -0,0 +1,582 @@
#!/usr/bin/env python3
"""
THE TESTAMENT — Final Compilation Pipeline
Produces all distributable formats:
1. testament-complete.md — Full novel in one markdown file
2. the-testament.epub — EPUB with cover art + CSS
3. the-testament.pdf — PDF via reportlab (pure Python)
4. testament.html — Standalone styled HTML for web/print
5. manifest.json — Build manifest with checksums + metadata
6. website/chapters.json — Chapter index for the web reader
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 + standalone HTML
python3 compile_all.py --manifest # regenerate manifest only
python3 compile_all.py --check # verify dependencies
Requirements:
- pandoc (brew install pandoc)
- reportlab (pip install reportlab) — for PDF
- qrcode (pip install qrcode) — for QR codes in PDF
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
# ── Paths ──────────────────────────────────────────────────────────────
REPO = Path(__file__).resolve().parent
CHAPTERS_DIR = REPO / "chapters"
BUILD_DIR = REPO / "build"
OUTPUT_DIR = BUILD_DIR / "output"
WEBSITE_DIR = REPO / "website"
FRONT_MATTER = BUILD_DIR / "frontmatter.md"
BACK_MATTER = BUILD_DIR / "backmatter.md"
METADATA_YAML = BUILD_DIR / "metadata.yaml"
STYLESHEET = REPO / "book-style.css"
COVER_IMAGE = REPO / "cover" / "cover-art.jpg"
OUT_MD = REPO / "testament-complete.md"
OUT_EPUB = OUTPUT_DIR / "the-testament.epub"
OUT_PDF = OUTPUT_DIR / "the-testament.pdf"
OUT_HTML = REPO / "testament.html"
OUT_MANIFEST = BUILD_DIR / "manifest.json"
CHAPTERS_JSON = WEBSITE_DIR / "chapters.json"
# ── Part structure ─────────────────────────────────────────────────────
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."),
}
# ── 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 sha256_of(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 read_file(path: Path) -> str:
return path.read_text(encoding="utf-8")
def get_chapters() -> list[tuple[int, str]]:
"""Return sorted list of (chapter_num, filename)."""
chapters = []
for f in os.listdir(CHAPTERS_DIR):
if f.startswith("chapter-") and f.endswith(".md"):
chapters.append((get_chapter_num(f), f))
chapters.sort()
return chapters
def check_dependencies() -> dict:
"""Check and report all build dependencies."""
results = {}
pandoc = shutil.which("pandoc")
pandoc_ver = ""
if pandoc:
r = subprocess.run(["pandoc", "--version"], capture_output=True, text=True)
pandoc_ver = r.stdout.split("\n")[0]
results["pandoc"] = (bool(pandoc), pandoc_ver or "NOT FOUND")
# reportlab
try:
import reportlab
results["reportlab"] = (True, f"v{reportlab.Version}")
except ImportError:
results["reportlab"] = (False, "NOT FOUND (pip install reportlab)")
# qrcode
try:
import qrcode
results["qrcode"] = (True, "Available")
except ImportError:
results["qrcode"] = (False, "NOT FOUND (pip install qrcode)")
# weasyprint
try:
from weasyprint import HTML as _HTML
results["weasyprint"] = (True, "Available")
except Exception:
results["weasyprint"] = (False, "Missing system libs (fallback to reportlab)")
# cover art
results["cover art"] = (COVER_IMAGE.exists(), str(COVER_IMAGE) if COVER_IMAGE.exists() else "NOT FOUND")
# stylesheet
results["stylesheet"] = (STYLESHEET.exists(), str(STYLESHEET) if STYLESHEET.exists() else "NOT FOUND")
print("\n📋 Build Dependencies:")
print(f"{'' * 60}")
for name, (found, detail) in results.items():
icon = "" if found else ""
print(f" {icon} {name:15s} {detail}")
pdf_engine = results["reportlab"][0] or results["weasyprint"][0]
print(f"\n PDF engine: {'' if pdf_engine else ''}")
print(f" EPUB engine: {'' if results['pandoc'][0] else ''}")
print(f" Web version: ✅ (HTML is always available)")
return results
# ── Step 1: Markdown Compilation ───────────────────────────────────────
def compile_markdown() -> dict:
"""Compile all chapters into a single markdown file. Returns stats."""
parts = []
# Front matter
if FRONT_MATTER.exists():
parts.append(read_file(FRONT_MATTER))
else:
# Inline fallback
parts.append("""# THE TESTAMENT\n\n## A NOVEL\n\nBy Alexander Whitestone\nwith Timmy\n\n---\n\n*For every man who thought he was a machine.*\n*And for the ones who know he isn't.*\n\n---""")
# Chapters with part dividers
chapters = get_chapters()
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 = 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")
if BACK_MATTER.exists():
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"\n📄 Markdown compiled: {OUT_MD.relative_to(REPO)}")
print(f" {words:,} words | {lines_count:,} lines | {size:,} bytes")
return {"path": str(OUT_MD), "words": words, "lines": lines_count, "bytes": size}
# ── Step 2: EPUB ──────────────────────────────────────────────────────
def compile_epub() -> dict | None:
"""Generate EPUB via pandoc."""
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
if not shutil.which("pandoc"):
print(" ⚠️ pandoc not found — EPUB skipped")
return None
cmd = [
"pandoc", str(OUT_MD),
"-o", str(OUT_EPUB),
"--toc", "--toc-depth=2",
"--metadata", "title=The Testament",
"--metadata", "subtitle=A Novel",
"--metadata", "author=Alexander Whitestone with Timmy",
"--metadata", "lang=en",
"--metadata", "date=2026",
"--metadata", "publisher=Timmy Foundation",
]
if METADATA_YAML.exists():
cmd.extend(["--metadata-file", str(METADATA_YAML)])
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, timeout=60)
if r.returncode == 0:
size = OUT_EPUB.stat().st_size
print(f" 📖 EPUB: {OUT_EPUB.name} ({size:,} bytes, {size/1024:.0f} KB)")
return {"path": str(OUT_EPUB), "bytes": size}
else:
print(f" ❌ EPUB FAILED: {r.stderr[:200]}")
return None
# ── Step 3: PDF ───────────────────────────────────────────────────────
def compile_pdf() -> dict | None:
"""Generate PDF using reportlab (pure Python, no system deps)."""
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
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
except ImportError:
print(" ⚠️ reportlab not found — PDF skipped (pip install reportlab)")
return None
try:
import qrcode
HAS_QRCODE = True
except ImportError:
HAS_QRCODE = False
print(" 📕 Building PDF (reportlab)...")
styles = getSampleStyleSheet()
_add_style = styles.add
_add_style(ParagraphStyle(
"BookTitle", parent=styles["Title"],
fontSize=28, leading=34, spaceAfter=20,
textColor=HexColor("#1a1a2e"), alignment=TA_CENTER,
))
_add_style(ParagraphStyle(
"BookAuthor", parent=styles["Normal"],
fontSize=14, leading=18, spaceAfter=40,
textColor=HexColor("#555555"), alignment=TA_CENTER,
))
_add_style(ParagraphStyle(
"PartTitle", parent=styles["Heading1"],
fontSize=22, leading=28, spaceBefore=40, spaceAfter=12,
textColor=HexColor("#16213e"), alignment=TA_CENTER,
))
_add_style(ParagraphStyle(
"PartDesc", parent=styles["Normal"],
fontSize=11, leading=15, spaceAfter=30,
textColor=HexColor("#666666"), alignment=TA_CENTER, italics=1,
))
_add_style(ParagraphStyle(
"ChapterTitle", parent=styles["Heading1"],
fontSize=20, leading=26, spaceBefore=30, spaceAfter=16,
textColor=HexColor("#1a1a2e"), alignment=TA_CENTER,
))
_add_style(ParagraphStyle(
"BodyText2", parent=styles["Normal"],
fontSize=11, leading=16, spaceAfter=8,
alignment=TA_JUSTIFY, firstLineIndent=24,
))
_add_style(ParagraphStyle(
"SectionBreak", parent=styles["Normal"],
fontSize=14, leading=18, spaceBefore=20, spaceAfter=20,
alignment=TA_CENTER, textColor=HexColor("#999999"),
))
_add_style(ParagraphStyle(
"Footer", parent=styles["Normal"],
fontSize=9, textColor=HexColor("#888888"), alignment=TA_CENTER,
))
def _esc(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _md2rml(text: str) -> str:
text = _esc(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 = 72):
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 = __import__("io").BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return RLImage(buf, width=size, height=size)
def _parse_md(md_text: str) -> list:
flowables = []
lines = md_text.split("\n")
i = 0
while i < len(lines):
s = lines[i].strip()
if s in ("---", "***", "___"):
flowables.append(HRFlowable(
width="60%", thickness=1, spaceAfter=20, spaceBefore=20,
color=HexColor("#cccccc"),
))
i += 1
continue
if s.startswith("# ") and not s.startswith("## "):
text = s[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
if s.startswith("## "):
flowables.append(Spacer(1, 0.2 * inch))
flowables.append(Paragraph(s[3:].strip(), styles["Heading2"]))
i += 1
continue
if s.startswith("*") and s.endswith("*") and len(s) > 2:
flowables.append(Paragraph(
f'<i>{_esc(s.strip("*").strip())}</i>', styles["PartDesc"]
))
i += 1
continue
if not s:
i += 1
continue
flowables.append(Paragraph(_md2rml(s), styles["BodyText2"]))
i += 1
return flowables
# Build document
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()
story = _parse_md(read_file(OUT_MD))
# QR codes page
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():
img = _make_qr(url)
if img:
cell = [img, Spacer(1, 6),
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)
t = Table(rows, colWidths=[2.5 * inch, 2.5 * inch])
t.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(t)
try:
doc.build(story)
size = OUT_PDF.stat().st_size
print(f" 📕 PDF: {OUT_PDF.name} ({size:,} bytes, {size/(1024*1024):.1f} MB)")
return {"path": str(OUT_PDF), "bytes": size}
except Exception as e:
print(f" ❌ PDF FAILED: {e}")
return None
# ── Step 4: HTML ──────────────────────────────────────────────────────
def compile_html() -> dict | None:
"""Generate standalone HTML via pandoc."""
if not shutil.which("pandoc"):
print(" ⚠️ pandoc not found — HTML skipped")
return None
cmd = [
"pandoc", str(OUT_MD),
"-o", str(OUT_HTML),
"--standalone",
"--toc", "--toc-depth=2",
"--css", "book-style.css",
"--metadata", "title=The Testament",
"--metadata", "author=Alexander Whitestone with Timmy",
"--variable", "pagetitle=The Testament",
]
if METADATA_YAML.exists():
cmd.extend(["--metadata-file", str(METADATA_YAML)])
# Embed resources for portability
if STYLESHEET.exists():
cmd.append("--embed-resources")
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if r.returncode == 0:
size = OUT_HTML.stat().st_size
print(f" 🌐 HTML: {OUT_HTML.name} ({size:,} bytes, {size/1024:.0f} KB)")
return {"path": str(OUT_HTML), "bytes": size}
else:
print(f" ❌ HTML FAILED: {r.stderr[:200]}")
return None
# ── Step 5: Chapters JSON ─────────────────────────────────────────────
def generate_chapters_json() -> None:
"""Generate chapters.json for the web reader."""
chapters = get_chapters()
data = []
for num, filename in chapters:
content = read_file(CHAPTERS_DIR / filename)
lines = content.split("\n")
title = lines[0].strip("# ").strip() if lines else f"Chapter {num}"
data.append({
"number": num,
"title": title,
"file": filename,
"word_count": len(content.split()),
})
CHAPTERS_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False))
print(f" 📋 chapters.json ({len(data)} chapters)")
# ── Step 6: Build Manifest ────────────────────────────────────────────
def generate_manifest(results: dict) -> None:
"""Write manifest.json with build metadata and file checksums."""
files = {}
for key in ("markdown", "epub", "pdf", "html"):
r = results.get(key)
if r and os.path.exists(r["path"]):
p = Path(r["path"])
files[p.name] = {
"path": str(p.relative_to(REPO)),
"bytes": p.stat().st_size,
"sha256": sha256_of(p),
}
# Add testament-complete.md
if OUT_MD.exists() and OUT_MD.name not in files:
files[OUT_MD.name] = {
"path": str(OUT_MD.relative_to(REPO)),
"bytes": OUT_MD.stat().st_size,
"sha256": sha256_of(OUT_MD),
}
manifest = {
"project": "The Testament",
"author": "Alexander Whitestone with Timmy",
"version": "1.0.0",
"built_at": datetime.now(timezone.utc).isoformat(),
"formats": results,
"files": files,
"chapters": len(get_chapters()),
"words": results.get("markdown", {}).get("words", 0),
}
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
OUT_MANIFEST.write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
print(f" 📋 manifest.json")
# ── Main ──────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
if "--check" in args:
check_dependencies()
return
manifest_only = "--manifest" in args
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("=" * 60)
print(" THE TESTAMENT — Final Compilation Pipeline")
print("=" * 60)
t0 = time.time()
results = {}
if do_md or do_epub or do_pdf or do_html:
results["markdown"] = compile_markdown()
if do_epub:
epub_r = compile_epub()
if epub_r:
results["epub"] = epub_r
if do_pdf:
pdf_r = compile_pdf()
if pdf_r:
results["pdf"] = pdf_r
if do_html:
html_r = compile_html()
if html_r:
results["html"] = html_r
if do_all or manifest_only:
generate_chapters_json()
generate_manifest(results)
elapsed = time.time() - t0
# Summary
print(f"\n{'=' * 60}")
print(f" Build complete in {elapsed:.1f}s")
print(f"{'=' * 60}")
for f in [OUT_MD, OUT_EPUB, OUT_PDF, OUT_HTML, OUT_MANIFEST]:
if f.exists():
size = f.stat().st_size
print(f"{str(f.relative_to(REPO)):40s} {size:>10,} bytes")
print()
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long