- 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
583 lines
20 KiB
Python
583 lines
20 KiB
Python
#!/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("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
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()
|