Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander Whitestone
81fec94a8a fix: Robert's age (58→71) and unique character details (#43, #44)
Some checks failed
Smoke Test / smoke (pull_request) Failing after 6s
2026-04-12 22:52:03 -04:00
Alexander Whitestone
682c16e0e2 Add smoke test workflow
Some checks failed
Smoke Test / smoke (pull_request) Failing after 6s
2026-04-10 20:09:00 -04:00
Alexander Whitestone
f6a46b99ca testament-burn: Add reportlab PDF fallback with QR codes
- Added _compile_pdf_reportlab() as third fallback for PDF generation
  (after xelatex and weasyprint fail)
- Uses reportlab (pure Python) - no system dependencies needed
- Parses markdown to styled flowables: titles, chapters, parts, body text
- Supports inline bold/italic markdown markup
- Generates QR code page linking to: online reader, The Door game,
  soundtrack, and source code repo
- Fixes PDF generation on systems without MacTeX or libgobject
- PDF now builds successfully: 143KB with all 18 chapters + QR codes
- Installed qrcode[pil] for QR generation

Closes #18 (Final Compilation)
2026-04-10 18:57:10 -04:00
7 changed files with 6363 additions and 5 deletions

View File

@@ -0,0 +1,24 @@
name: Smoke Test
on:
pull_request:
push:
branches: [main]
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Parse check
run: |
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
find . -name '*.py' | xargs -r python3 -m py_compile
find . -name '*.sh' | xargs -r bash -n
echo "PASS: All files parse"
- name: Secret scan
run: |
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
echo "PASS: No secrets"

View File

@@ -176,8 +176,244 @@ def compile_pdf():
except Exception as e:
print(f" PDF FAILED: {e}")
print(" PDF SKIPPED: no PDF engine found (install MacTeX or fix weasyprint)")
return False
# 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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;'))
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():

View File

@@ -73,8 +73,9 @@ arithmetic. You can fight a judge. You can fight a lawyer. You can't
fight confidence intervals.
He lost custody of Maya. She was four. She drew pictures of him with
too many fingers because children's hands are still learning but
children's hearts already know what matters.
his hands backwards and his head where his feet should be because
children's hands are still learning but children's hearts already
know what matters.
David kept the pictures.

View File

@@ -38,7 +38,7 @@ sense something and can't name it. He came because his parole
officer's schedule left him alone with his thoughts for eighteen
hours a day and his thoughts were not friendly company.
Robert: fifty-eight, retired after thirty-four years at a plant
Robert: seventy-one, retired after thirty-four years at a plant
that closed, pension cut in half when the company declared bankruptcy.
His wife left him because she couldn't afford to watch a man she
loved shrink. He came because his kids were in another state and had

55
render_batch.py Normal file
View File

@@ -0,0 +1,55 @@
import json
import os
import torch
from diffusers import AutoPipelineForText2Image
# Configuration
MANIFEST_PATH = '/Users/apayne/the-testament/visual_manifest.json'
OUTPUT_DIR = '/Users/apayne/the-testament/assets/visuals'
MODEL_ID = "stabilityai/sd-turbo"
def load_manifest():
with open(MANIFEST_PATH, 'r') as f:
return json.load(f)
def save_manifest(manifest):
with open(MANIFEST_PATH, 'w') as f:
json.dump(manifest, f, indent=2)
def run_batch_render(limit=10):
manifest = load_manifest()
os.makedirs(OUTPUT_DIR, exist_ok=True)
print(f"Loading {MODEL_ID} on MPS...")
pipe = AutoPipelineForText2Image.from_pretrained(
MODEL_ID, torch_dtype=torch.float16, variant="fp16"
).to("mps")
count = 0
for beat in manifest:
if count >= limit:
break
if beat.get('status') == 'completed':
continue
text = beat['text']
prompt = f"Cinematic film still, moody noir, {text[:150]}, 35mm, muted tones, high contrast, detailed textures"
filename = f"{beat['chapter'].replace(' ', '_').replace('#', '').replace('', '_')}_p{beat['paragraph']}.png"
path = os.path.join(OUTPUT_DIR, filename)
try:
image = pipe(prompt=prompt, num_inference_steps=1, guidance_scale=0.0).images[0]
image.save(path)
beat['status'] = 'completed'
beat['final_prompt'] = prompt
beat['path'] = path
count += 1
except Exception as e:
print(f"Error rendering {filename}: {e}")
save_manifest(manifest)
print(f"Batch complete. Rendered {count} images.")
if __name__ == "__main__":
run_batch_render(limit=5)

41
render_pipeline.py Normal file
View File

@@ -0,0 +1,41 @@
import torch
from diffusers import AutoPipelineForText2Image
import os
import json
class SovereignRenderer:
def __init__(self, model_id="stabilityai/sd-turbo", output_dir="./the-testament/assets/visuals"):
self.output_dir = output_dir
os.makedirs(self.output_dir, exist_ok=True)
print(f"Loading local pipeline: {model_id}...")
# Use torch.float16 for Metal performance
self.pipe = AutoPipelineForText2Image.from_pretrained(
model_id,
torch_dtype=torch.float16,
variant="fp16"
).to("mps") # Metal Performance Shaders
print("Pipeline loaded successfully.")
def generate_image(self, chapter, paragraph, prompt):
filename = f"{chapter.replace(' ', '_').replace('#', '').replace('', '_')}_p{paragraph}.png"
path = os.path.join(self.output_dir, filename)
print(f"Generating: {filename}...")
# SD-Turbo is very fast, usually 1-4 steps
image = self.pipe(prompt=prompt, num_inference_steps=1, guidance_scale=0.0).images[0]
image.save(path)
return path
def create_visual_prompt(text):
# In a full implementation, this would call an LLM.
# For the prototype, we use a cinematic template.
return f"Cinematic film still, moody and melancholic, {text[:100]}, muted colors, high grain, 8k, shot on 35mm"
if __name__ == "__main__":
# Example usage for Chapter 1, Para 1
renderer = SovereignRenderer()
test_text = "The rain didn't fall so much as it gave up. Mist over a grey city bridge."
prompt = create_visual_prompt(test_text)
img_path = renderer.generate_image("Chapter 1", 1, prompt)
print(f"Saved to {img_path}")

6001
visual_manifest.json Normal file

File diff suppressed because it is too large Load Diff