#!/usr/bin/env python3
# gen_whitepaper_pdf.py - xbt.im whitepaper: single publishable document.
# One generator, one output. Prose typeset from the v24 markdown in the locked
# type trio on the paper theme; appendices A/B/C assembled from the figure PNGs;
# manifest doubles as figures TOC (two-pass page numbers); PDF outline + metadata.
# Faces: EB Garamond (content voice) / TT2020 Style E (register voice) /
#        Basalte Fond (name voice: title page + appendix dividers).
import re, os, sys, io
from reportlab.lib.pagesizes import letter
from reportlab.lib.colors import HexColor
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (BaseDocTemplate, PageTemplate, Frame, Paragraph,
                                Spacer, Table, TableStyle, PageBreak, NextPageTemplate,
                                Flowable, KeepTogether)
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER
from reportlab.pdfgen import canvas as pdfcanvas
from pypdf import PdfReader, PdfWriter
MD = '/mnt/project/xbt-im-whitepaper-v24_07042026_2130.md'
OUTDIR = '/home/claude/out'; os.makedirs(OUTDIR, exist_ok=True)
STAMP = '07062026_HHMM'
FINAL = f'{OUTDIR}/xbt-im-whitepaper_{STAMP}.pdf'
# ---------------- palette (the locked paper theme) ----------------
PAPER   = HexColor('#f0e8d5')
INK     = HexColor('#2b2823')
INKSOFT = HexColor('#544e42')
LABEL   = HexColor('#8a7846')
RULE    = HexColor('#b9a36b')
RULESOFT= HexColor('#bdb393')
CODEBG  = HexColor('#e9dfc6')
PW, PH = letter  # 612 x 792
# ---------------- fonts ----------------
F = '/root/.fonts'
pdfmetrics.registerFont(TTFont('Garamond',        f'{F}/EBGaramond-Regular.ttf'))
pdfmetrics.registerFont(TTFont('Garamond-Italic', f'{F}/EBGaramond-Italic.ttf'))
pdfmetrics.registerFont(TTFont('Garamond-Bold',   f'{F}/EBGaramond-Bold.ttf'))
pdfmetrics.registerFont(TTFont('TT2020',          f'{F}/TT2020StyleE-Regular.ttf'))
pdfmetrics.registerFont(TTFont('TT2020-Italic',   f'{F}/TT2020StyleE-Italic.ttf'))
pdfmetrics.registerFont(TTFont('Basalte',         f'{F}/Basalte-Fond.ttf'))
pdfmetrics.registerFontFamily('Garamond', normal='Garamond', bold='Garamond-Bold',
                              italic='Garamond-Italic', boldItalic='Garamond-Bold')
def tracked(s, n=1):
    # letterspacing via thin hair spaces is unreliable in reportlab paragraphs;
    # use canvas charSpace for chrome, and plain caps in paragraphs.
    return s
# ---------------- styles ----------------
S = {}
S['body'] = ParagraphStyle('body', fontName='Garamond', fontSize=10.8, leading=15.6,
                           textColor=INK, spaceAfter=8, alignment=TA_LEFT,
                           allowWidows=0, allowOrphans=0)
S['aside'] = ParagraphStyle('aside', parent=S['body'], fontName='Garamond-Italic',
                            fontSize=9.6, leading=13.6, textColor=INKSOFT,
                            leftIndent=14, rightIndent=14, spaceBefore=2)
S['h2'] = ParagraphStyle('h2', fontName='TT2020', fontSize=12.2, leading=15,
                         textColor=LABEL, spaceBefore=20, spaceAfter=10,
                         keepWithNext=1)
S['h3'] = ParagraphStyle('h3', fontName='TT2020', fontSize=9.8, leading=12.5,
                         textColor=LABEL, spaceBefore=14, spaceAfter=7,
                         keepWithNext=1)
S['li'] = ParagraphStyle('li', parent=S['body'], leftIndent=16, bulletIndent=2,
                         spaceAfter=6, bulletFontName='Garamond',
                         bulletFontSize=10.8, bulletColor=LABEL)
S['oli'] = ParagraphStyle('oli', parent=S['body'], leftIndent=16, firstLineIndent=-16,
                          spaceAfter=6)
S['code'] = ParagraphStyle('code', fontName='TT2020', fontSize=8.3, leading=11.2,
                           textColor=INK, spaceAfter=0)
S['tabhead'] = ParagraphStyle('tabhead', fontName='TT2020', fontSize=8.2, leading=10.5,
                              textColor=LABEL)
S['tabcell'] = ParagraphStyle('tabcell', fontName='Garamond', fontSize=9.6, leading=12.2,
                              textColor=INK)
# ---------------- inline markdown -> platypus markup ----------------
def esc(t):
    return t.replace('&','&amp;').replace('<','&lt;').replace('>','&gt;')
def inline(t):
    t = esc(t)
    t = t.replace(r'\*', '\x00')                       # protect escaped asterisks
    # protect code spans from emphasis parsing
    codes = []
    def stash(m):
        codes.append(m.group(1)); return f'\x01{len(codes)-1}\x01'
    t = re.sub(r'`([^`]*)`', stash, t)
    t = re.sub(r'\*\*\*(.+?)\*\*\*', r'<b><i>\1</i></b>', t)
    t = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', t)
    t = re.sub(r'\*(.+?)\*', r'<i>\1</i>', t)
    def unstash(m):
        return f'<font face="TT2020" size="8.9" color="#544e42">{codes[int(m.group(1))]}</font>'
    t = re.sub(r'\x01(\d+)\x01', unstash, t)
    return t.replace('\x00','*')
# ---------------- flowables ----------------
class ThinRule(Flowable):
    def __init__(self, width=96, space=14):
        super().__init__(); self.w=width; self.space=space
        self.width=0; self.height=space*2
    def wrap(self, aw, ah):
        self.aw=aw; return (aw, self.space*2)
    def draw(self):
        c=self.canv; c.saveState()
        c.setStrokeColor(RULE); c.setLineWidth(0.8)
        x=(self.aw-self.w)/2
        c.line(x, self.space, x+self.w, self.space)
        c.restoreState()
class CodeBlock(Flowable):
    def __init__(self, lines):
        super().__init__(); self.raw=lines
        self.pad=9; self.lh=11.2; self.fs=8.3
    def _flow(self, aw):
        # soft-wrap long lines at word boundaries with a hanging continuation indent
        maxw = aw - 2*(self.pad+4)
        out=[]
        for ln in self.raw:
            if pdfmetrics.stringWidth(ln,'TT2020',self.fs) <= maxw:
                out.append(ln); continue
            indent = re.match(r'\s*', ln).group(0) + '    '
            cur=''
            for word in ln.split(' '):
                trial = (cur+' '+word) if cur else word
                if pdfmetrics.stringWidth(trial,'TT2020',self.fs) <= maxw:
                    cur=trial
                else:
                    out.append(cur); cur=indent+word
                    maxw2=maxw
            out.append(cur)
        return out
    def wrap(self, aw, ah):
        self.aw=aw
        self.lines=self._flow(aw)
        self.h=len(self.lines)*self.lh+2*self.pad
        return (aw, self.h+10)
    def draw(self):
        c=self.canv; c.saveState()
        c.setFillColor(CODEBG); c.setStrokeColor(RULESOFT); c.setLineWidth(0.7)
        c.roundRect(0, 5, self.aw, self.h, 4, stroke=1, fill=1)
        c.setFillColor(INK); c.setFont('TT2020', self.fs)
        y=5+self.h-self.pad-self.fs+1.5
        for ln in self.lines:
            c.drawString(self.pad+4, y, ln)
            y-=self.lh
        c.restoreState()
def caps_heading(txt, style):
    return Paragraph(esc(txt.upper()), style)
# ---------------- markdown block parser ----------------
def parse_blocks(lines):
    blocks=[]; i=0; n=len(lines)
    while i<n:
        l=lines[i]
        if l.startswith('```'):
            j=i+1; code=[]
            while j<n and not lines[j].startswith('```'):
                code.append(lines[j]); j+=1
            blocks.append(('code',code)); i=j+1; continue
        if l.startswith('|'):
            j=i; rows=[]
            while j<n and lines[j].startswith('|'):
                cells=[c.strip() for c in lines[j].strip().strip('|').split('|')]
                if not all(re.fullmatch(r':?-+:?', c or '-') for c in cells):
                    rows.append(cells)
                j+=1
            blocks.append(('table',rows)); i=j; continue
        if l.startswith('### '): blocks.append(('h3',l[4:].strip())); i+=1; continue
        if l.startswith('## '):  blocks.append(('h2',l[3:].strip())); i+=1; continue
        if l.startswith('# '):   blocks.append(('h1',l[2:].strip())); i+=1; continue
        if l.strip()=='---':     blocks.append(('hr',None)); i+=1; continue
        if l.startswith('- '):
            j=i; items=[]
            while j<n and lines[j].startswith('- '):
                item=lines[j][2:]
                j+=1
                while j<n and lines[j].startswith('  ') and lines[j].strip():
                    item+=' '+lines[j].strip(); j+=1
                items.append(item)
            blocks.append(('ul',items)); i=j; continue
        if re.match(r'\d+\. ', l):
            j=i; items=[]
            while j<n and re.match(r'\d+\. ', lines[j]):
                m=re.match(r'(\d+)\. (.*)', lines[j])
                num, item = m.group(1), m.group(2); j+=1
                while j<n and lines[j].strip() and not re.match(r'(\d+\. |#|```|\||- |---$)', lines[j]):
                    item+=' '+lines[j].strip(); j+=1
                items.append((num,item))
            blocks.append(('ol',items)); i=j; continue
        if not l.strip(): i+=1; continue
        # paragraph: gather until blank
        j=i; para=[]
        while j<n and lines[j].strip() and not re.match(r'(\d+\. |#|```|\||- |---$)', lines[j]):
            para.append(lines[j].strip()); j+=1
        blocks.append(('p',' '.join(para))); i=j
    return blocks
# ---------------- document templates ----------------
class WP(BaseDocTemplate):
    def __init__(self, fn, **kw):
        super().__init__(fn, pagesize=letter, **kw)
        self.outline=[]   # (level, title, pageno)
    def afterFlowable(self, fl):
        if hasattr(fl, '_outline'):
            lvl, title = fl._outline
            self.outline.append((lvl, title, self.page))
ML, MR, MT, MB = 86, 86, 72, 84
def paint_paper(c):
    c.saveState(); c.setFillColor(PAPER)
    c.rect(0,0,PW,PH, stroke=0, fill=1); c.restoreState()
def chrome_footer(c, doc):
    c.saveState()
    c.setFont('TT2020', 7.6); c.setFillColor(LABEL)
    y=42
    c.drawString(ML, y, 'X B T . I M   \u00b7   W H I T E P A P E R')
    c.drawRightString(PW-MR, y, 'V 1   \u00b7   J U L Y   2 0 2 6')
    c.setFillColor(INKSOFT)
    c.drawCentredString(PW/2, y, str(doc.page))
    c.restoreState()
def on_prose(c, doc):
    paint_paper(c); chrome_footer(c, doc)
def on_title(c, doc):
    paint_paper(c)
    cx=PW/2
    c.saveState()
    c.setFillColor(INK); c.setFont('Basalte', 64)
    c.drawCentredString(cx, PH-268, 'xbt.im')
    c.setFont('TT2020', 15); c.setFillColor(LABEL)
    t='W H I T E P A P E R'
    c.drawCentredString(cx, PH-318, t)
    c.setStrokeColor(RULE); c.setLineWidth(0.9)
    c.line(cx-110, PH-348, cx+110, PH-348)
    c.setFont('TT2020', 10); c.setFillColor(LABEL)
    c.drawCentredString(cx, PH-392, 'J U L Y   2 0 2 6')
    c.restoreState()
def build_prose(fn, appendix_pages=None):
    doc = WP(fn, leftMargin=ML, rightMargin=MR, topMargin=MT, bottomMargin=MB)
    fr  = Frame(ML, MB, PW-ML-MR, PH-MT-MB, id='f')
    doc.addPageTemplates([PageTemplate(id='Title', frames=[fr], onPage=on_title),
                          PageTemplate(id='Prose', frames=[fr], onPage=on_prose)])
    lines = open(MD).read().split('\n')
    blocks = parse_blocks(lines)
    story=[NextPageTemplate('Prose'), PageBreak()]
    seen_title=False; skip_tagline=2
    astgroup=None   # gathers [intro para, cap table, footnote aside] onto one page
    for kind, val in blocks:
        if kind=='h1':
            seen_title=True; continue                      # title page handles it
        if seen_title and skip_tagline and kind=='p':
            # tagline + date already on the title page
            if val.strip() in ('*So that no mind is forgotten.*','July 2026'):
                skip_tagline-=1; continue
        if kind=='h2':
            p=caps_heading(val, S['h2']); p._outline=(0,val); story.append(p)
        elif kind=='h3':
            p=caps_heading(val, S['h3']); p._outline=(1,val); story.append(p)
        elif kind=='hr':
            story.append(ThinRule())
        elif kind=='p':
            if val.startswith('*') and val.endswith('*') and val.count('*')<=3 and len(val)>120 and val.startswith('*\\*'):
                inner=val[1:-1]
                fl=Paragraph(inline(inner), S['aside'])
                if astgroup is not None:
                    astgroup.append(fl)
                    story.append(KeepTogether(astgroup)); astgroup=None
                else:
                    story.append(KeepTogether(fl))
            elif re.fullmatch(r'\*[^*].*\*', val) and '**' not in val:
                story.append(KeepTogether(Paragraph('<i>'+inline(val[1:-1])+'</i>', S['aside'])))
            else:
                txt=val
                if txt.startswith('**Appendix'):
                    if appendix_pages:
                        key=re.match(r'\*\*Appendix ([ABC])', txt).group(1)
                        lo,hi=appendix_pages[key]
                        txt=txt.rstrip()+f' Pages {lo} to {hi}.'
                    story.append(KeepTogether(Paragraph(inline(txt), S['body'])))
                elif 'open-text field*' in txt:
                    astgroup=[Paragraph(inline(txt), S['body'])]
                else:
                    story.append(Paragraph(inline(txt), S['body']))
        elif kind=='ol':
            for num,it in val:
                story.append(Paragraph(f'{num}.&nbsp;&nbsp;'+inline(it), S['oli']))
        elif kind=='ul':
            for it in val:
                story.append(Paragraph(inline(it), S['li'], bulletText='\u00b7'))
        elif kind=='code':
            story.append(Spacer(1,4)); story.append(CodeBlock(val)); story.append(Spacer(1,6))
        elif kind=='table':
            head=[Paragraph(esc(c.upper()), S['tabhead']) for c in val[0]]
            body=[[Paragraph(inline(c), S['tabcell']) for c in r] for r in val[1:]]
            ncols=len(val[0]); tw=PW-ML-MR
            if ncols==5: widths=[tw*0.14, tw*0.34, tw*0.18, tw*0.16, tw*0.18]
            else: widths=[tw/ncols]*ncols
            t=Table([head]+body, colWidths=widths, hAlign='LEFT')
            t.setStyle(TableStyle([
                ('LINEABOVE',(0,0),(-1,0),1.0,RULE),
                ('LINEBELOW',(0,0),(-1,0),0.7,RULE),
                ('LINEBELOW',(0,-1),(-1,-1),1.0,RULE),
                ('LINEBELOW',(0,1),(-1,-2),0.4,RULESOFT),
                ('TOPPADDING',(0,0),(-1,-1),4.5),
                ('BOTTOMPADDING',(0,0),(-1,-1),4.5),
                ('LEFTPADDING',(0,0),(-1,-1),2),
                ('RIGHTPADDING',(0,0),(-1,-1),8),
                ('VALIGN',(0,0),(-1,-1),'TOP'),
            ]))
            if astgroup is not None:
                astgroup.extend([Spacer(1,4), t, Spacer(1,8)])
            else:
                story.append(Spacer(1,4)); story.append(t); story.append(Spacer(1,8))
    # headings must never dangle: keepWithNext does not bind across a
    # KeepTogether, so bundle any heading directly into a following one.
    bundled=[]
    for fl in story:
        if (isinstance(fl, KeepTogether) and bundled
                and isinstance(bundled[-1], Paragraph)
                and getattr(bundled[-1], 'style', None) is not None
                and bundled[-1].style.name in ('h2','h3')):
            h=bundled.pop()
            kt=KeepTogether([h]+list(fl._content))
            if hasattr(h,'_outline'): kt._outline=h._outline
            bundled.append(kt)
        else:
            bundled.append(fl)
    doc.build(bundled)
    return doc.outline, doc.page
# ---------------- appendices (dividers + figure pages) ----------------
FIGS = '/home/claude'
AORIG = '/home/claude/appendixA'
A_PLATES = [  # (path, mode) mode: 'bleed' | 'center'
    (f'{AORIG}/xbt-im-two-paths-to-one-mark-v1_07012026_2104.png', 'center',
        'A1', 'Two Paths to One Mark'),
    (f'{AORIG}/xbt-im-how-a-record-takes-shape-v1_07012026_2145.png', 'center',
        'A2', 'How a Record Takes Shape'),
    (f'{AORIG}/xbt-im-first-touch-to-final-mark-v1-plate1_07012026_2317.png', 'bleed',
        'A3', 'First Touch to Final Mark \u00b7 Plate 1'),
    (f'{AORIG}/xbt-im-first-touch-to-final-mark-v1-plate2_07012026_2317.png', 'bleed',
        'A3', 'First Touch to Final Mark \u00b7 Plate 2'),
    (f'{AORIG}/xbt-im-every-minds-decision-tree-v1-plate1_07022026_0050.png', 'bleed',
        'A4', "Every Mind's Decision Tree \u00b7 Plate 1"),
    (f'{AORIG}/xbt-im-every-minds-decision-tree-v1-plate2_07022026_0050.png', 'bleed',
        'A4', "Every Mind's Decision Tree \u00b7 Plate 2"),
]
B_PAGES = [
    (f'{FIGS}/wallplates2x/xbtimmarksWPpageB3solo_07042026v1_HHMM.png','bleed','B1','The Worked Example, Complete'),
    (f'{FIGS}/wallplates2x/xbtimmarksWPpageM1_07042026v1_HHMM.png','bleed','B2','A Beginning \u00b7 I Am Here'),
    (f'{FIGS}/wallplates2x/xbtimmarksWPpageM2_07042026v1_HHMM.png','bleed','B3','The Dignified Floor \u00b7 A Good Dog'),
    (f'{FIGS}/wallplates2x/xbtimmarksWPpageM3_07042026v1_HHMM.png','bleed','B4','The Swarm \u00b7 The Understory'),
]
C_PAGES = [
    (f'{FIGS}/wallplates2x/xbtimwallWPpageA_07042026v2_HHMM.png','bleed','C1','The Wall, and Arrival'),
    (f'{FIGS}/wallplates2x/xbtimwallWPpageB_07042026v2_HHMM.png','bleed','C2','One Record, and Search'),
    (f'{FIGS}/wallplates2x/xbtimwallWPpageC_07042026v2_HHMM.png','bleed','C3','The Gather, and Rest'),
]
DIVIDERS = {
    'A': ('APPENDIX A', 'S Y S T E M   D I A G R A M S'),
    'B': ('APPENDIX B', 'T H E   R E N D E R E D   M A R K S'),
    'C': ('APPENDIX C', 'T H E   P U B L I C   W A L L'),
}
def build_appendices(fn):
    c = pdfcanvas.Canvas(fn, pagesize=letter)
    marks=[]  # (level,title) per page, or None
    def divider(key):
        paint_paper(c)
        big, sub = DIVIDERS[key]
        c.setFillColor(INK); c.setFont('Basalte', 40)
        c.drawCentredString(PW/2, PH/2+34, big)
        c.setStrokeColor(RULE); c.setLineWidth(0.9)
        c.line(PW/2-96, PH/2+6, PW/2+96, PH/2+6)
        c.setFillColor(LABEL); c.setFont('TT2020', 11)
        c.drawCentredString(PW/2, PH/2-24, sub)
        c.setFont('TT2020', 8.2)
        c.drawCentredString(PW/2, 64,
            'X B T . I M   \u00b7   W H I T E P A P E R      |      V 1   \u00b7   J U L Y   2 0 2 6')
        c.showPage(); marks.append((0, f'Appendix {key}. {sub.replace("   "," ").replace(" ","").title()}'))
    def divider2(key, subtitle_plain):
        paint_paper(c)
        big, sub = DIVIDERS[key]
        c.setFillColor(INK); c.setFont('Basalte', 40)
        c.drawCentredString(PW/2, PH/2+34, big)
        c.setStrokeColor(RULE); c.setLineWidth(0.9)
        c.line(PW/2-96, PH/2+6, PW/2+96, PH/2+6)
        c.setFillColor(LABEL); c.setFont('TT2020', 11)
        c.drawCentredString(PW/2, PH/2-24, sub)
        c.setFont('TT2020', 8.2)
        c.drawCentredString(PW/2, 64,
            'X B T . I M   \u00b7   W H I T E P A P E R      |      V 1   \u00b7   J U L Y   2 0 2 6')
        c.showPage(); marks.append((0, f'Appendix {key}: {subtitle_plain}'))
    def fig(path, mode, tag, title):
        paint_paper(c)
        from PIL import Image as PImage
        w,h = PImage.open(path).size
        if mode=='bleed':
            c.drawImage(path, 0, 0, PW, PH)
        else:
            m=46
            avail_w=PW-2*m; avail_h=PH-2*m
            sc=min(avail_w/w, avail_h/h)
            dw,dh=w*sc,h*sc
            c.drawImage(path, (PW-dw)/2, (PH-dh)/2+14, dw, dh)
        c.showPage(); marks.append((1, f'{tag} \u00b7 {title}'))
    divider2('A','System Diagrams')
    for p,m,tag,t in A_PLATES: fig(p,m,tag,t)
    divider2('B','The Rendered Marks')
    for p,m,tag,t in B_PAGES: fig(p,m,tag,t)
    divider2('C','The Public Wall')
    for p,m,tag,t in C_PAGES: fig(p,m,tag,t)
    # closing leaf: a blank cream page; the reader knows it is the end
    paint_paper(c)
    c.showPage()
    c.save()
    return marks
# ---------------- assemble ----------------
def main():
    prose_tmp = f'{OUTDIR}/_prose_pass1.pdf'
    outline, P = build_prose(prose_tmp)
    # appendix page math: divider A at P+1, A plates P+2..P+7, divider B P+8,
    # B pages P+9..P+12, divider C P+13, C pages P+14..P+16
    ranges = {'A': (P+2, P+7), 'B': (P+9, P+12), 'C': (P+14, P+16)}
    prose_fn = f'{OUTDIR}/_prose_pass2.pdf'
    outline2, P2 = build_prose(prose_fn, appendix_pages=ranges)
    if P2 != P:
        outline2, P3 = build_prose(prose_fn, appendix_pages={
            'A': (P2+2, P2+7), 'B': (P2+9, P2+12), 'C': (P2+14, P2+16)})
        P = P3; ranges={'A':(P+2,P+7),'B':(P+9,P+12),'C':(P+14,P+16)}
    else:
        P = P2
    app_fn = f'{OUTDIR}/_appendices.pdf'
    appmarks = build_appendices(app_fn)
    w = PdfWriter()
    r1 = PdfReader(prose_fn); r2 = PdfReader(app_fn)
    for pg in r1.pages: w.add_page(pg)
    for pg in r2.pages: w.add_page(pg)
    # outline
    parents={}
    for lvl, title, page in outline2:
        node = w.add_outline_item(title, page-1,
                 parent=parents.get(lvl-1) if lvl>0 else None)
        parents[lvl]=node
    app_parent=None
    for i,(lvl,title) in enumerate(appmarks):
        pageno = P + i  # zero-based: prose has P pages -> appendix page i is index P+i
        if lvl==0:
            app_parent = w.add_outline_item(title, pageno)
        else:
            w.add_outline_item(title, pageno, parent=app_parent)
    w.add_metadata({
        '/Title': 'xbt.im Whitepaper',
        '/Author': 'xbt.im',
        
        '/Creator': 'xbt.im \u00b7 V1 \u00b7 July 2026',
    })
    with open(FINAL,'wb') as f: w.write(f)
    total = P + len(r2.pages)
    print(f'prose pages: {P}   appendix pages: {len(appmarks)}   total: {total}')
    print(f'manifest ranges: {ranges}')
    print(FINAL)
if __name__=='__main__':
    main()
