Files
digital-mujtahid/assemble_pdf.py
T

337 lines
18 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 Mujtahid' 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_mujtahid")
CHAPTERS_DIR = os.path.join(OUTPUT_DIR, "chapters")
PDF_PATH = os.path.join(OUTPUT_DIR, "pdf", "The_Digital_Mujtahid.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_fatwa = ParagraphStyle('Fatwa', 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 = [
("Fatwa #1: The Problem Statement", "What Problem Are We Solving?", "Problem Quadrant: Symptom vs Root Cause / User Pain vs Business Pain / Known vs Unknown"),
("Fatwa #2: Evidence Gathering", "Continuous Discovery as Istiqsa'", "Evidence Quadrant: Quantitative / Qualitative / Behavioral / Attitudinal"),
("Fatwa #3: Stakeholder Consultation", "Shura as Discovery", "Shura Quadrant: Users / Team / Business / Regulators"),
("Fatwa #4: The Product Decision", "The Fatwa as Definition of Done", "Decision Quadrant: Reversible vs Irreversible / High Stakes vs Low Stakes / Data-Rich vs Data-Poor"),
("Fatwa #5: Minimum Viable Fatwa", "MVP as Minimum Viable Fatwa", "MVP Quadrant: Viable / Valuable / Usable / Feasible"),
("Fatwa #6: Technical Debt", "Israf vs Investment", "Debt Quadrant: Prudent / Reckless / Deliberate / Inadvertent"),
("Fatwa #7: Pricing", "Adl (Justice) in Pricing", "Pricing Quadrant: Cost-Plus / Value-Based / Dynamic / Freemium"),
("Fatwa #8: Growth Metrics", "Barakah vs Vanity Metrics", "Growth Quadrant: AARRR → Barakah Filter"),
("Fatwa #9: Team Organization", "Shura at Scale", "Org Quadrant: Functional / Cross-Functional / Squad / Guild / Chapter"),
("Fatwa #10: Legacy Product", "Sadaqah Jariyah as Compound Impact", "Legacy Loop: Create → Ship → Measure → Compound → Endow → Teach"),
]
def clean_text(text):
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
parts = text.split('**')
for i in range(1, len(parts), 2):
parts[i] = f'<b>{parts[i]}</b>'
text = ''.join(parts)
parts = text.split('*')
for i in range(1, len(parts), 2):
parts[i] = f'<i>{parts[i]}</i>'
text = ''.join(parts)
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 Mujtahid: Product Thinking from First Principles",
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 Mujtahid", style_title))
story.append(Paragraph("Product Thinking from First Principles", style_subtitle))
story.append(Paragraph("10 Fatwas 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('A Product Lead & Mujtahid 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 Mujtahid', '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>Fatwa #{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"Fatwa_0{ch_num}.md") if ch_num < 10 else os.path.join(CHAPTERS_DIR, f"Fatwa_{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 Fatwa #{ch_num}: {title}...")
sections = parse_chapter(md)
# Chapter header
story.append(Paragraph(f'Fatwa #{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()