diff --git a/build/build.py b/build/build.py index 6d569e0..21721ca 100755 --- a/build/build.py +++ b/build/build.py @@ -1,134 +1,485 @@ #!/usr/bin/env python3 """ -The Testament — Book Compilation Pipeline +THE TESTAMENT — Build System -Compiles all chapters into a single manuscript and generates: - - PDF (print-ready) - - EPUB (e-reader) - -Requirements: - - pandoc (brew install pandoc / apt install pandoc) - - TeX Live or similar for PDF (brew install --cask mactex / apt install texlive-full) +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 # Build all formats - python3 build/build.py --pdf # PDF only - python3 build/build.py --epub # EPUB only - python3 build/build.py --md # Combined markdown only + 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 subprocess -import sys import os +import re +import sys +import subprocess +import shutil from pathlib import Path -ROOT = Path(__file__).parent.parent -BUILD = ROOT / "build" -CHAPTERS_DIR = ROOT / "chapters" +# 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" -def find_chapters(): - """Find all chapter files, sorted by number.""" - chapters = sorted(CHAPTERS_DIR.glob("chapter-*.md")) - if not chapters: - print("ERROR: No chapter files found in", CHAPTERS_DIR) - sys.exit(1) - return chapters +# Output files +OUT_MD = REPO / "testament-complete.md" +OUT_EPUB = OUTPUT_DIR / "the-testament.epub" +OUT_PDF = OUTPUT_DIR / "the-testament.pdf" -def combine_markdown(chapters): - """Combine all parts into a single markdown file.""" +# 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 - front = BUILD / "frontmatter.md" - if front.exists(): - parts.append(front.read_text()) + parts.append(FRONT_MATTER.read_text()) # Chapters - for ch in chapters: - parts.append(ch.read_text()) + 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 - back = BUILD / "backmatter.md" - if back.exists(): - parts.append(back.read_text()) + parts.append("\n---\n") + parts.append(BACK_MATTER.read_text()) - combined = "\n\n\newpage\n\n".join(parts) - output = BUILD / "the-testament-full.md" - output.write_text(combined) - print(f"Combined markdown: {output} ({len(combined)} chars)") - return output + compiled = '\n'.join(parts) + OUT_MD.write_text(compiled) -def build_pdf(md_file): - """Build PDF using pandoc + LaTeX.""" - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - output = OUTPUT_DIR / "the-testament.pdf" - metadata = BUILD / "metadata.yaml" - - cmd = [ - "pandoc", - str(md_file), - "-o", str(output), - "--metadata-file", str(metadata), - "--pdf-engine=xelatex", - "--highlight-style=tango", - "-V", "colorlinks=true", - "-V", "linkcolor=blue", - "-V", "urlcolor=blue", - ] - - print("Building PDF...") - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - print(f"PDF build failed:\n{result.stderr}") - return False - print(f"PDF: {output} ({output.stat().st_size / 1024:.0f} KB)") + words = len(compiled.split()) + size = OUT_MD.stat().st_size + print(f" Markdown: {OUT_MD.name} ({words:,} words, {size:,} bytes)") return True -def build_epub(md_file): - """Build EPUB using pandoc.""" + +def compile_epub(): + """Generate EPUB via pandoc.""" OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - output = OUTPUT_DIR / "the-testament.epub" - metadata = BUILD / "metadata.yaml" cmd = [ - "pandoc", - str(md_file), - "-o", str(output), - "--metadata-file", str(metadata), - "--toc", - "--epub-chapter-level=1", + "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", ] - print("Building EPUB...") - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - print(f"EPUB build failed:\n{result.stderr}") + 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 - print(f"EPUB: {output} ({output.stat().st_size / 1024:.0f} KB)") - return True + + +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'{_escape(text)}', styles['PartDesc'])) + i += 1 + continue + + # Empty line + if not stripped: + i += 1 + continue + + # Bold text: **text** -> text + # Italic text: *text* -> text + # 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'\1', text) + # Italic: *text* + text = re.sub(r'\*(.+?)\*', r'\1', 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'{label}', 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 = set(sys.argv[1:]) - build_all = not args or "--all" in args + 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 - chapters = find_chapters() - print(f"Found {len(chapters)} chapters") + print("=" * 50) + print(" THE TESTAMENT — Build System") + print("=" * 50) - md_file = combine_markdown(chapters) + # Step 1: Always compile markdown first + if do_md or do_epub or do_pdf or do_html: + compile_markdown() - if build_all or "--md" in args: - print("Markdown combined successfully.") + # Step 2: EPUB + if do_epub: + compile_epub() - if build_all or "--pdf" in args: - if not build_pdf(md_file): - print("PDF build failed (pandoc/LaTeX may not be installed). Skipping.") + # Step 3: PDF + if do_pdf: + compile_pdf() - if build_all or "--epub" in args: - if not build_epub(md_file): - print("EPUB build failed (pandoc may not be installed). Skipping.") + # 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)}") - print("\nDone.") if __name__ == "__main__": main()