Files
digital-khalifah/assemble_pdf.py
T

368 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Assemble 'The Digital Khalifah' PDF — Playbook Style, Penguin 6×9in."""
import os, re, sys
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
from reportlab.lib.colors import HexColor
from reportlab.platypus import (
Paragraph, Spacer, PageBreak, Frame, PageTemplate, BaseDocTemplate
)
OUTPUT_DIR = os.path.expanduser("~/odysseus_book/digital_khalifah")
CHAPTERS_DIR = os.path.join(OUTPUT_DIR, "chapters")
PDF_PATH = os.path.join(OUTPUT_DIR, "pdf", "The_Digital_Khalifah.pdf")
MARGIN = 0.85 * inch
FOOTER_H = 0.4 * inch
PAGE_W = 6 * inch
PAGE_H = 9 * inch
DARK_TEAL = HexColor('#0D4F4F')
GOLD = HexColor('#C8A951')
DARK = HexColor('#1a1a1a')
GREY = HexColor('#666666')
LIGHT_GREY = HexColor('#f0f0f0')
ACCENT_ORANGE = HexColor('#E67E22')
style_title = ParagraphStyle('Title', fontName='Helvetica-Bold', fontSize=26,
leading=32, alignment=TA_CENTER, textColor=DARK_TEAL, spaceAfter=8)
style_subtitle = ParagraphStyle('Subtitle', fontName='Helvetica-Oblique', fontSize=13,
leading=17, alignment=TA_CENTER, textColor=GOLD, spaceAfter=20)
style_author = ParagraphStyle('Author', fontName='Helvetica', fontSize=12,
leading=16, alignment=TA_CENTER, textColor=GREY, spaceAfter=28)
style_copyright = ParagraphStyle('Copyright', fontName='Helvetica', fontSize=9,
leading=12, alignment=TA_CENTER, textColor=GREY)
style_ch_num = ParagraphStyle('ChNum', fontName='Helvetica-Bold', fontSize=10,
leading=13, alignment=TA_CENTER, textColor=GOLD, spaceAfter=4, spaceBefore=18)
style_ch_title = ParagraphStyle('ChTitle', fontName='Helvetica-Bold', fontSize=20,
leading=26, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=8, spaceBefore=4)
style_subtitle_ch = ParagraphStyle('SubtitleCh', fontName='Helvetica-Oblique', fontSize=12,
leading=16, alignment=TA_LEFT, textColor=ACCENT_ORANGE, spaceAfter=14, spaceBefore=2)
style_framework = ParagraphStyle('Framework', fontName='Helvetica-Bold', fontSize=11,
leading=15, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=6, spaceBefore=14,
backColor=HexColor('#f0f0f0'), borderColor=GOLD, borderWidth=1, borderPadding=8)
style_body = ParagraphStyle('Body', fontName='Times-Roman', fontSize=10,
leading=14.5, alignment=TA_JUSTIFY, textColor=DARK, spaceAfter=7, firstLineIndent=0)
style_dialogue = ParagraphStyle('Dialogue', fontName='Times-Roman', fontSize=10,
leading=14.5, alignment=TA_LEFT, textColor=DARK, spaceAfter=5, leftIndent=24)
style_pl = ParagraphStyle('PL', fontName='Times-Bold', fontSize=10,
leading=14.5, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=3, leftIndent=24)
style_muj = ParagraphStyle('Muj', fontName='Times-Bold', fontSize=10,
leading=14.5, alignment=TA_LEFT, textColor=GOLD, spaceAfter=3, leftIndent=24)
style_lie = ParagraphStyle('Lie', fontName='Times-Bold', fontSize=10.5,
leading=15, alignment=TA_LEFT, textColor=HexColor('#C0392B'), spaceAfter=6, spaceBefore=10,
backColor=HexColor('#FDF2F2'), borderColor=HexColor('#C0392B'), borderWidth=1, borderPadding=10)
style_protocol = ParagraphStyle('Protocol', fontName='Times-Roman', fontSize=10,
leading=14.5, alignment=TA_LEFT, textColor=DARK, spaceAfter=6, leftIndent=36)
style_reflection = ParagraphStyle('Reflection', fontName='Times-Italic', fontSize=10.5,
leading=15, alignment=TA_CENTER, textColor=GREY, spaceAfter=16, spaceBefore=16,
backColor=HexColor('#F8F9FA'), borderColor=GREY, borderWidth=0.5, borderPadding=12)
style_principle = ParagraphStyle('Principle', fontName='Times-Bold', fontSize=11,
leading=15, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=6, spaceBefore=10,
backColor=HexColor('#EBF5FB'), borderColor=DARK_TEAL, borderWidth=1, borderPadding=10)
style_toc_ch = ParagraphStyle('TOCCh', fontName='Helvetica-Bold', fontSize=11,
leading=17, alignment=TA_LEFT, textColor=DARK_TEAL)
style_toc_sub = ParagraphStyle('TOCSub', fontName='Helvetica', fontSize=9.5,
leading=13, alignment=TA_LEFT, textColor=GREY)
style_hook = ParagraphStyle('Hook', fontName='Times-Italic', fontSize=10.5,
leading=15, alignment=TA_LEFT, textColor=DARK, spaceAfter=10, spaceBefore=6,
backColor=HexColor('#EBF5FB'), borderColor=DARK_TEAL, borderWidth=1, borderPadding=10)
CHAPTERS = [
("Principle #1: The Problem Statement", "What Problem Are We Solving?", "Problem Quadrant: Symptom vs Root Cause / User Pain vs Business Pain / Known vs Unknown"),
("Principle #2: Evidence Gathering", "Continuous Discovery as Istiqsa'", "Evidence Quadrant: Quantitative / Qualitative / Behavioral / Attitudinal"),
("Principle #3: Stakeholder Consultation", "Shura as Discovery", "Shura Quadrant: Users / Team / Business / Regulators"),
("Principle #4: The Product Decision", "The Principle as Definition of Done", "Decision Quadrant: Reversible vs Irreversible / High Stakes vs Low Stakes / Data-Rich vs Data-Poor"),
("Principle #5: Minimum Viable Principle", "MVP as Minimum Viable Principle", "MVP Quadrant: Viable / Valuable / Usable / Feasible"),
("Principle #6: Technical Debt", "Israf vs Investment", "Debt Quadrant: Prudent / Reckless / Deliberate / Inadvertent"),
("Principle #7: Pricing", "Adl (Justice) in Pricing", "Pricing Quadrant: Cost-Plus / Value-Based / Dynamic / Freemium"),
("Principle #8: Growth Metrics", "Barakah vs Vanity Metrics", "Growth Quadrant: AARRR → Barakah Filter"),
("Principle #9: Team Organization", "Shura at Scale", "Org Quadrant: Functional / Cross-Functional / Squad / Guild / Chapter"),
("Principle #10: Legacy Product", "Sadaqah Jariyah as Compound Impact", "Legacy Loop: Create → Ship → Measure → Compound → Endow → Teach"),
]
def clean_text(text):
import re
import markdown
# Convert markdown to HTML using the markdown library
# This properly handles nested emphasis, code blocks, etc.
md = markdown.Markdown(
extensions=[
'markdown.extensions.extra',
'markdown.extensions.codehilite',
'markdown.extensions.smarty',
],
output_format='html'
)
html = md.convert(text)
# Strip any remaining unwanted tags (script, style, etc.)
import re
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
# ReportLab only supports a subset of HTML tags.
# We'll keep the basic formatting tags it supports.
allowed_tags = ['b', 'i', 'u', 'strong', 'em', 'br', 'p', 'br/', 'sub', 'sup']
# Remove any tags not in allowed list
def replace_tag(match):
tag = match.group(1).lower()
if tag.startswith('/'):
tag_name = tag[1:]
else:
tag_name = tag
if tag_name in allowed_tags:
return match.group(0)
return ''
text = re.sub(r'<(/?)([a-zA-Z][a-zA-Z0-9]*)[^>]*>', lambda m: m.group(0) if m.group(2).lower() in allowed_tags else '', text)
# Clean up any remaining malformed tags
text = re.sub(r'<[^>]*>', '', text)
return text
def parse_chapter(md_text):
lines = md_text.split('\n')
sections = {'scenario': [], 'discovery': [], 'evidence': [], 'shura': [], 'fatwa': [], 'protocol': [], 'muhasaba': []}
current = None
for line in md_text.split('\n'):
stripped = line.strip()
if not stripped:
continue
low = stripped.lower()
if low.startswith('# the scenario') or low.startswith('## the scenario') or low == 'the scenario':
current = 'scenario'
continue
elif low.startswith('# discovery') or low.startswith('## discovery') or low.startswith('discovery') or low.startswith('istiqsa'):
current = 'discovery'
continue
elif low.startswith('# evidence') or low.startswith('## evidence') or low.startswith('evidence') or low.startswith('istidlal'):
current = 'evidence'
continue
elif low.startswith('# shura') or low.startswith('## shura') or low.startswith('shura'):
current = 'shura'
continue
elif low.startswith('# the fatwa') or low.startswith('## the fatwa') or low.startswith('fatwa') or low.startswith('hukm'):
current = 'fatwa'
continue
elif low.startswith('# the protocol') or low.startswith('## the protocol') or low.startswith('protocol'):
current = 'protocol'
continue
elif low.startswith('# muhasaba') or low.startswith('## muhasaba') or low.startswith('muhasaba') or low.startswith('retrospective'):
current = 'muhasaba'
continue
if stripped.startswith('#'):
continue
if stripped == '---' or stripped == '***':
continue
if current:
sections[current].append(stripped)
for k in sections:
sections[k] = '\n\n'.join(sections[k])
return sections
class BookDoc(BaseDocTemplate):
def __init__(self, *args, **kw):
BaseDocTemplate.__init__(self, *args, **kw)
self.page_count = 0
def footer_fn(canvas, doc):
canvas.saveState()
canvas.setFont('Helvetica', 8)
canvas.setFillColor(HexColor('#999999'))
canvas.drawCentredString(PAGE_W / 2, 0.35 * inch, f'— {doc.page} —')
canvas.restoreState()
def build_pdf():
os.makedirs(os.path.join(OUTPUT_DIR, 'pdf'), exist_ok=True)
frame = Frame(MARGIN, MARGIN + FOOTER_H,
PAGE_W - 2 * MARGIN,
PAGE_H - 2 * MARGIN - FOOTER_H, id='normal')
doc = BookDoc(PDF_PATH, pagesize=(PAGE_W, PAGE_H),
leftMargin=MARGIN, rightMargin=MARGIN,
topMargin=MARGIN, bottomMargin=MARGIN,
title="The Digital Khalifah: Stewardship at Scale",
author='Jauhari Che Wan')
doc.addPageTemplates([PageTemplate(id='main', frames=frame, onPage=footer_fn)])
story = []
# Title page
story.append(Spacer(1, PAGE_H * 0.18))
story.append(Paragraph("The Digital Khalifah", style_title))
story.append(Paragraph("Stewardship at Scale", style_subtitle))
story.append(Paragraph("10 Principles for Building Products That Matter", ParagraphStyle('Tagline',
fontName='Helvetica-Oblique', fontSize=12, leading=15, alignment=TA_CENTER, textColor=HexColor('#666666'), spaceAfter=24)))
story.append(Spacer(1, 14))
story.append(Paragraph('An Org Designer & Khalifah Playbook', ParagraphStyle('StyleLine',
fontName='Helvetica', fontSize=10, leading=13, alignment=TA_CENTER, textColor=HexColor('#888888'), spaceAfter=20)))
story.append(Spacer(1, 14))
story.append(Paragraph('Jauhari Che Wan', style_author))
story.append(Spacer(1, PAGE_H * 0.1))
story.append(Paragraph('PRODUCT LEAD × MUJTAHID × MAQASID',
ParagraphStyle('Tag', parent=style_copyright, fontName='Helvetica-Bold', fontSize=9, textColor=GOLD)))
story.append(PageBreak())
# Copyright
story.append(Spacer(1, PAGE_H * 0.28))
for line in ['PRODUCT LEAD × MUJTAHID PLAYBOOK', '', 'The Digital Khalifah', 'Jauhari Che Wan',
'', 'Based on Classical Usul al-Fiqh', 'Marty Cagan\'s Product Framework', 'Maqasid al-Shariah',
'', 'Copyright \u00a9 2025', 'All rights reserved',
'', 'Typeset in Times / Helvetica', 'Printed in Malaysia']:
if line == '':
story.append(Spacer(1, 4))
else:
story.append(Paragraph(line, style_copyright))
story.append(PageBreak())
# Dedication
story.append(Spacer(1, PAGE_H * 0.28))
story.append(Paragraph('For every Product Lead who\'s ever asked: "What would the Mujtahid do?"', style_dedication := ParagraphStyle('Ded', fontName='Times-Italic', fontSize=11, leading=16, alignment=TA_CENTER, textColor=DARK, spaceAfter=24)))
story.append(PageBreak())
# TOC
story.append(Paragraph('Contents', ParagraphStyle('TOCHeader',
fontName='Helvetica-Bold', fontSize=18, leading=24, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=20)))
for i, (title, subtitle, _) in enumerate(CHAPTERS, 1):
story.append(Paragraph(f'<b>Principle #{i}</b>: {title}', style_toc_ch))
story.append(Paragraph(f' <i>{subtitle}</i>', style_toc_sub))
story.append(Spacer(1, 4))
story.append(PageBreak())
# Chapters
for ch_num, (title, subtitle, framework) in enumerate(CHAPTERS, 1):
fname = os.path.join(CHAPTERS_DIR, f"Principle_0{ch_num}.md") if ch_num < 10 else os.path.join(CHAPTERS_DIR, f"Principle_{ch_num}.md")
if not os.path.exists(fname):
print(f" WARNING: {fname} not found")
continue
with open(fname, 'r', encoding='utf-8') as f:
md = f.read()
print(f" Adding Principle #{ch_num}: {title}...")
sections = parse_chapter(md)
# Chapter header
story.append(Paragraph(f'Principle #{ch_num}', style_ch_num))
story.append(Paragraph(title, style_ch_title))
story.append(Paragraph(subtitle, style_subtitle_ch))
story.append(Spacer(1, 8))
# SCENARIO
if sections['scenario']:
hook_text = clean_text(' '.join(sections['scenario'].split('\n')))
story.append(Paragraph(hook_text, style_hook))
story.append(Spacer(1, 6))
# DISCOVERY (ISTIQSA')
if sections['discovery']:
story.append(Paragraph('DISCOVERY (ISTIQSA\')', ParagraphStyle('DiscHeader',
fontName='Helvetica-Bold', fontSize=12, leading=16, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=6, spaceBefore=10)))
for para in sections['discovery'].split('\n\n'):
clean = clean_text(para.strip())
if not clean:
continue
low = clean.lower()
if low.startswith('product_lead:') or low.startswith('product lead:'):
story.append(Paragraph(f'<b>PRODUCT_LEAD:</b> {clean[13:].strip() if clean.lower().startswith("product_lead:") else clean[12:].strip()}', style_pl))
elif low.startswith('mujtahid:'):
story.append(Paragraph(f'<b>MUJTAHID:</b> {clean[9:].strip()}', style_muj))
else:
story.append(Paragraph(clean, style_body))
story.append(Spacer(1, 6))
# EVIDENCE (ISTIDLAL)
if sections['evidence']:
story.append(Paragraph('EVIDENCE (ISTIDLAL)', ParagraphStyle('EvidHeader',
fontName='Helvetica-Bold', fontSize=12, leading=16, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=6, spaceBefore=10)))
for para in sections['evidence'].split('\n\n'):
clean = clean_text(para.strip())
if not clean:
continue
low = clean.lower()
if low.startswith('product_lead:') or low.startswith('product lead:'):
story.append(Paragraph(f'<b>PRODUCT_LEAD:</b> {clean[13:].strip() if clean.lower().startswith("product_lead:") else clean[12:].strip()}', style_pl))
elif low.startswith('mujtahid:'):
story.append(Paragraph(f'<b>MUJTAHID:</b> {clean[9:].strip()}', style_muj))
else:
story.append(Paragraph(clean, style_body))
story.append(Spacer(1, 6))
# SHURA
if sections['shura']:
story.append(Paragraph('SHURA (CONSULTATION)', ParagraphStyle('ShuraHeader',
fontName='Helvetica-Bold', fontSize=12, leading=16, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=6, spaceBefore=10)))
for para in sections['shura'].split('\n\n'):
clean = clean_text(para.strip())
if not clean:
continue
low = clean.lower()
if low.startswith('product_lead:') or low.startswith('product lead:'):
story.append(Paragraph(f'<b>PRODUCT_LEAD:</b> {clean[13:].strip() if clean.lower().startswith("product_lead:") else clean[12:].strip()}', style_pl))
elif low.startswith('mujtahid:'):
story.append(Paragraph(f'<b>MUJTAHID:</b> {clean[9:].strip()}', style_muj))
else:
story.append(Paragraph(clean, style_body))
story.append(Spacer(1, 6))
# FATWA (HUKM)
if sections['fatwa']:
story.append(Paragraph('THE FATWA (HUKM)', style_fatwa))
for para in sections['fatwa'].split('\n\n'):
clean = clean_text(para.strip())
if not clean:
continue
low = clean.lower()
if low.startswith('hukm:'):
story.append(Paragraph(f'<b>HUKM:</b> {clean[5:].strip()}', style_body))
elif low.startswith('daleel:'):
story.append(Paragraph(f'<b>DALEEL:</b> {clean[7:].strip()}', style_body))
elif low.startswith('maqsad:'):
story.append(Paragraph(f'<b>MAQSAD:</b> {clean[7:].strip()}', style_body))
elif low.startswith('shurut:') or low.startswith('shurut:'):
story.append(Paragraph(f'<b>SHURUT:</b> {clean[7:].strip()}', style_body))
elif low.startswith('munkathirat:'):
story.append(Paragraph(f'<b>MUNKATHIRAT:</b> {clean[12:].strip()}', style_body))
else:
story.append(Paragraph(clean, style_body))
story.append(Spacer(1, 8))
# PROTOCOL
if sections['protocol']:
story.append(Paragraph('THE PROTOCOL — Do This This Sprint', ParagraphStyle('ProtoHeader',
fontName='Helvetica-Bold', fontSize=12, leading=16, alignment=TA_LEFT, textColor=DARK_TEAL, spaceAfter=6, spaceBefore=10)))
for i, para in enumerate(sections['protocol'].split('\n\n'), 1):
clean = clean_text(para.strip())
if clean:
if clean.lower().startswith(f'step {i}') or clean.lower().startswith(f'step {i}:'):
clean = clean.split(':', 1)[-1].strip()
story.append(Paragraph(f'<b>STEP {i}:</b> {clean}', style_protocol))
story.append(Spacer(1, 8))
# MUHASABA
if sections['muhasaba']:
clean = clean_text(' '.join(sections['muhasaba'].split('\n')))
story.append(Paragraph(clean, style_reflection))
story.append(PageBreak())
print('Building PDF...')
doc.build(story)
print(f'Done: {PDF_PATH}')
import subprocess
result = subprocess.run(['wc', '-c', PDF_PATH], capture_output=True, text=True)
size_kb = int(result.stdout.split()[0]) / 1024
with open(PDF_PATH, 'rb') as f:
data = f.read()
pages = len(re.findall(rb'/Type\s*/Page[^s]', data))
print(f'Pages: {pages}, Size: {size_kb:.0f} KB')
if __name__ == '__main__':
build_pdf()