#!/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'{parts[i]}' text = ''.join(parts) parts = text.split('*') for i in range(1, len(parts), 2): parts[i] = f'{parts[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'Fatwa #{i}: {title}', style_toc_ch)) story.append(Paragraph(f' {subtitle}', 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'PRODUCT_LEAD: {clean[13:].strip() if clean.lower().startswith("product_lead:") else clean[12:].strip()}', style_pl)) elif low.startswith('mujtahid:'): story.append(Paragraph(f'MUJTAHID: {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'PRODUCT_LEAD: {clean[13:].strip() if clean.lower().startswith("product_lead:") else clean[12:].strip()}', style_pl)) elif low.startswith('mujtahid:'): story.append(Paragraph(f'MUJTAHID: {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'PRODUCT_LEAD: {clean[13:].strip() if clean.lower().startswith("product_lead:") else clean[12:].strip()}', style_pl)) elif low.startswith('mujtahid:'): story.append(Paragraph(f'MUJTAHID: {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'HUKM: {clean[5:].strip()}', style_body)) elif low.startswith('daleel:'): story.append(Paragraph(f'DALEEL: {clean[7:].strip()}', style_body)) elif low.startswith('maqsad:'): story.append(Paragraph(f'MAQSAD: {clean[7:].strip()}', style_body)) elif low.startswith('shurut:') or low.startswith('shurut:'): story.append(Paragraph(f'SHURUT: {clean[7:].strip()}', style_body)) elif low.startswith('munkathirat:'): story.append(Paragraph(f'MUNKATHIRAT: {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'STEP {i}: {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()