render_v3_pdf.py

#!/usr/bin/env python3
"""Render a positioning-paper markdown file to a shareable PDF.

Usage (from repo root):
    python3 docs/whitepapers/render_v3_pdf.py
        -> renders the v3.1 OKI paper to its committed PDF path (every default
           below tracks the current external edition)

    python3 docs/whitepapers/render_v3_pdf.py --help
        -> the CLI added 2026-09-02 (WS-1b) so the same renderer produces the
           platform whitepaper r3.6 PDF from docs/MIZOKI_3.5_WHITEPAPER_r3.6_SEP2026.md;
           the exact invocation is in docs/whitepapers/README.md.

Dependency: docs/whitepapers/requirements.txt (reportlab, pinned).
"""
from __future__ import annotations

import argparse
import re
from pathlib import Path

from reportlab.lib.colors import Color, HexColor, white, black
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
    KeepTogether,
    ListFlowable,
    ListItem,
    PageBreak,
    Paragraph,
    SimpleDocTemplate,
    Spacer,
    Table,
    TableStyle,
    HRFlowable,
)

NAVY = HexColor("#0B1F33")
GOLD = HexColor("#B0893E")
RULE = HexColor("#D6D1C4")
MUTED = HexColor("#4A5560")
ROW_ALT = HexColor("#F4F1EA")
BODY = HexColor("#1A1A1A")

ROOT = Path(__file__).resolve().parent
MD_PATH = ROOT / "MIZ_OKI_3.5_Operating_Knowledge_Intelligence_Whitepaper.md"
PDF_PATH = ROOT / "MIZ_OKI_3.5_Operating_Knowledge_Intelligence_Whitepaper.pdf"

# Defaults for the v3.1 external OKI paper. Update with its source edition.
DEFAULT_KICKER = "MIZ OKI 3.5"
DEFAULT_TITLE = "Operating Knowledge Intelligence"
DEFAULT_SUBTITLE = "External whitepaper for governed enterprise decisions."
DEFAULT_VERSION_LABEL = "v3.1"
DEFAULT_DATE_LABEL = "23 September 2026"
DEFAULT_SUPERSEDES_LABEL = "v3.0 (13 August 2026); July v2.0"
DEFAULT_COVER_LINES = (
    "Reconciled to MIZOKICloudRun measured state",
    "Platform ceiling: built, pre-benchmark",
    "Stage 3 recommend-only is the fleet default",
)
DEFAULT_HEADER_LEFT = "MIZ OKI 3.5  |  Operating Knowledge Intelligence"
DEFAULT_FOOTER_NOTE = "built, pre-benchmark  ·  External whitepaper"
DEFAULT_TAGLINE = "Evidence. Authority. Accountable outcomes."
DEFAULT_DOC_TITLE = "MIZ OKI 3.5 -- Operating Knowledge Intelligence"
DEFAULT_AUTHOR = "Media Intelligence"
BODY_START_CHOICES = ("first-h2", "after-rule")


def _styles() -> dict[str, ParagraphStyle]:
    base = getSampleStyleSheet()
    return {
        "cover_kicker": ParagraphStyle(
            "cover_kicker",
            parent=base["Normal"],
            fontName="Times-Italic",
            fontSize=11,
            textColor=GOLD,
            alignment=TA_LEFT,
            spaceAfter=10,
            tracking=1,
        ),
        "cover_title": ParagraphStyle(
            "cover_title",
            parent=base["Normal"],
            fontName="Times-Bold",
            fontSize=28,
            leading=34,
            textColor=white,
            alignment=TA_LEFT,
            spaceAfter=8,
        ),
        "cover_sub": ParagraphStyle(
            "cover_sub",
            parent=base["Normal"],
            fontName="Times-Roman",
            fontSize=13,
            leading=18,
            textColor=HexColor("#E8E4D9"),
            alignment=TA_LEFT,
            spaceAfter=6,
        ),
        "cover_meta": ParagraphStyle(
            "cover_meta",
            parent=base["Normal"],
            fontName="Helvetica",
            fontSize=9,
            leading=13,
            textColor=HexColor("#C9C3B4"),
            alignment=TA_LEFT,
            spaceAfter=3,
        ),
        "h1": ParagraphStyle(
            "h1",
            parent=base["Normal"],
            fontName="Times-Bold",
            fontSize=16,
            keepWithNext=True,
            leading=20,
            textColor=NAVY,
            spaceBefore=16,
            spaceAfter=8,
        ),
        "h2": ParagraphStyle(
            "h2",
            parent=base["Normal"],
            fontName="Times-Bold",
            fontSize=12.5,
            keepWithNext=True,
            leading=16,
            textColor=NAVY,
            spaceBefore=12,
            spaceAfter=6,
        ),
        "body": ParagraphStyle(
            "body",
            parent=base["Normal"],
            fontName="Times-Roman",
            fontSize=10.2,
            leading=14.4,
            textColor=BODY,
            alignment=TA_JUSTIFY,
            spaceAfter=8,
        ),
        "quote": ParagraphStyle(
            "quote",
            parent=base["Normal"],
            fontName="Times-Italic",
            fontSize=9.6,
            leading=13.4,
            textColor=MUTED,
            leftIndent=18,
            rightIndent=10,
            spaceAfter=8,
        ),
        "bullet": ParagraphStyle(
            "bullet",
            parent=base["Normal"],
            fontName="Times-Roman",
            fontSize=10.2,
            leading=14.4,
            textColor=BODY,
            leftIndent=14,
            bulletIndent=0,
            spaceAfter=3,
        ),
        "bullet_nested": ParagraphStyle(
            "bullet_nested",
            parent=base["Normal"],
            fontName="Times-Roman",
            fontSize=9.8,
            leading=13.8,
            textColor=BODY,
            leftIndent=30,
            bulletIndent=16,
            spaceAfter=3,
        ),
        "table_cell": ParagraphStyle(
            "table_cell",
            parent=base["Normal"],
            fontName="Helvetica",
            fontSize=7.8,
            leading=10.4,
            textColor=BODY,
        ),
        "table_head": ParagraphStyle(
            "table_head",
            parent=base["Normal"],
            fontName="Helvetica-Bold",
            fontSize=7.8,
            leading=10.4,
            textColor=white,
        ),
        "caption": ParagraphStyle(
            "caption",
            parent=base["Normal"],
            fontName="Helvetica-Oblique",
            fontSize=8,
            leading=11,
            textColor=MUTED,
            spaceAfter=10,
        ),
    }


def _inline(text: str) -> str:
    text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
    # Single-asterisk italics (added 2026-09-02; the OKI paper carries none, so its
    # render is unchanged). Runs only after bold so `**` pairs are never split.
    text = re.sub(r"(?<![*\w])\*(?!\*)([^*\n]+?)\*(?![*\w])", r"<i>\1</i>", text)
    text = re.sub(r"`([^`]+)`", r"<font face='Courier' size='8.5'>\1</font>", text)
    text = re.sub(
        r"\[([^\]]+)\]\((https?://[^)]+)\)",
        lambda m: '<link href="' + m.group(2).replace('"', '&quot;') + '" color="#0B5964">' + m.group(1) + '</link>',
        text,
    )
    text = text.replace(" -- ", " &mdash; ")
    return text


def _cover_version_line(version_label: str, date_label: str) -> str:
    # "v3.0" -> "Version 3.0  ·  <date>" (the pre-CLI cover string); any other label
    # ("Revision 3.6") is used as written.
    if re.fullmatch(r"v\d[\d.]*", version_label):
        return f"Version {version_label[1:]}  ·  {date_label}"
    return f"{version_label}  ·  {date_label}"


def build_settings(args: argparse.Namespace) -> dict:
    """Resolve CLI arguments into the strings the render bakes in. With no
    arguments the labels describe the current external edition (verified by
    tests/governance/test_platform_whitepaper_r36.py)."""
    version_label = args.version_label
    date_label = args.date_label
    cover_lines = [_cover_version_line(version_label, date_label)]
    cover_lines += list(args.cover_line) if args.cover_line else [DEFAULT_COVER_LINES[0]]
    cover_lines.append(f"Supersedes {args.supersedes_label}")
    if not args.cover_line:
        cover_lines += list(DEFAULT_COVER_LINES[1:])
    subject = args.subject or (
        f"External whitepaper {version_label}, reconciled {date_label}"
    )
    return {
        "input": Path(args.input).resolve(),
        "output": Path(args.output).resolve(),
        "kicker": args.kicker,
        "title": args.title,
        "subtitle": args.subtitle,
        "cover_lines": cover_lines,
        "header_left": args.header_left,
        "header_right": f"{version_label}  |  {date_label}",
        "footer_note": args.footer_note,
        "tagline": args.tagline,
        "doc_title": args.doc_title,
        "author": args.author,
        "subject": subject,
        "body_start": args.body_start,
    }


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description="Render a positioning-paper markdown file to PDF. With no "
                    "arguments, renders the v3.1 OKI paper exactly as before the CLI existed.",
    )
    p.add_argument("--input", default=str(MD_PATH), help="markdown source (default: the v3.0 OKI paper)")
    p.add_argument("--output", default=str(PDF_PATH), help="PDF to write (default: the committed OKI PDF)")
    p.add_argument("--kicker", default=DEFAULT_KICKER, help="small italic line above the cover title")
    p.add_argument("--title", default=DEFAULT_TITLE, help="cover title")
    p.add_argument("--subtitle", default=DEFAULT_SUBTITLE, help="cover subtitle")
    p.add_argument("--version-label", default=DEFAULT_VERSION_LABEL,
                   help="e.g. 'v3.0' or 'Revision 3.6'; appears in the running header and the cover")
    p.add_argument("--date-label", default=DEFAULT_DATE_LABEL, help="e.g. '13 August 2026'")
    p.add_argument("--supersedes-label", default=DEFAULT_SUPERSEDES_LABEL,
                   help="what this edition supersedes; rendered as 'Supersedes <label>' on the cover")
    p.add_argument("--cover-line", action="append", default=None,
                   help="extra cover line (repeatable). Replaces the default cover lines "
                        "between the version line and the supersedes line.")
    p.add_argument("--header-left", default=DEFAULT_HEADER_LEFT, help="running header, left")
    p.add_argument("--footer-note", default=DEFAULT_FOOTER_NOTE, help="running footer, left")
    p.add_argument("--tagline", default=DEFAULT_TAGLINE, help="italic line at the foot of the cover")
    p.add_argument("--doc-title", default=DEFAULT_DOC_TITLE, help="PDF metadata title")
    p.add_argument("--author", default=DEFAULT_AUTHOR, help="PDF metadata author")
    p.add_argument("--subject", default=None,
                   help="PDF metadata subject (default: derived from version/date labels)")
    p.add_argument("--body-start", choices=BODY_START_CHOICES, default="first-h2",
                   help="where the body begins: 'first-h2' skips everything before the first "
                        "'## ' line (OKI paper); 'after-rule' skips a '# / ## / ### / **meta**' "
                        "title block up to the first '---' rule (platform whitepaper r3.6), so "
                        "the cover comes from the labels above instead of the markdown title block")
    return p.parse_args(argv)


def _header_footer(canvas, doc) -> None:
    canvas.saveState()
    page = canvas.getPageNumber()
    if page == 1:
        canvas.restoreState()
        return
    s = doc.mizoki_settings
    canvas.setFillColor(NAVY)
    canvas.rect(0, letter[1] - 36, letter[0], 36, fill=1, stroke=0)
    canvas.setFillColor(GOLD)
    canvas.rect(0, letter[1] - 38, letter[0], 2, fill=1, stroke=0)
    canvas.setFillColor(white)
    canvas.setFont("Times-Roman", 8)
    canvas.drawString(0.85 * inch, letter[1] - 24, s["header_left"])
    canvas.drawRightString(letter[0] - 0.85 * inch, letter[1] - 24, s["header_right"])
    canvas.setFillColor(RULE)
    canvas.rect(0, 0, letter[0], 32, fill=1, stroke=0)
    canvas.setFillColor(NAVY)
    canvas.setFont("Helvetica", 8)
    canvas.drawString(0.85 * inch, 14, s["footer_note"])
    canvas.drawRightString(letter[0] - 0.85 * inch, 14, str(page - 1))
    canvas.restoreState()


def _cover(styles: dict[str, ParagraphStyle], story: list, s: dict) -> None:
    # Drawn as a full-bleed first page via a spacer + overlay in onFirstPage.
    story.append(Spacer(1, 1.6 * inch))
    story.append(Paragraph(_inline(s["kicker"]), styles["cover_kicker"]))
    story.append(Paragraph(_inline(s["title"]), styles["cover_title"]))
    story.append(Spacer(1, 0.15 * inch))
    story.append(Paragraph(_inline(s["subtitle"]), styles["cover_sub"]))
    story.append(Spacer(1, 0.35 * inch))
    story.append(
        HRFlowable(width="40%", thickness=1.5, color=GOLD, spaceAfter=16, hAlign="LEFT")
    )
    for line in s["cover_lines"]:
        story.append(Paragraph(_inline(line), styles["cover_meta"]))
    story.append(PageBreak())


def _on_first_page(canvas, doc) -> None:
    canvas.saveState()
    canvas.setFillColor(NAVY)
    canvas.rect(0, 0, letter[0], letter[1], fill=1, stroke=0)
    canvas.setFillColor(GOLD)
    canvas.rect(0, letter[1] - 8, letter[0], 8, fill=1, stroke=0)
    canvas.rect(0, 0, letter[0], 8, fill=1, stroke=0)
    canvas.setFillColor(HexColor("#132A44"))
    canvas.rect(0, 2.1 * inch, letter[0], 0.02 * inch, fill=1, stroke=0)
    canvas.setFillColor(GOLD)
    canvas.setFont("Times-Italic", 9)
    canvas.drawString(0.85 * inch, 0.55 * inch, doc.mizoki_settings["tagline"])
    canvas.restoreState()


def _parse_table(lines: list[str], styles: dict[str, ParagraphStyle]):
    rows = []
    for line in lines:
        cells = [c.strip() for c in line.strip().strip("|").split("|")]
        if all(set(c) <= set("-: ") and c for c in cells):
            continue
        rows.append(cells)
    if not rows:
        return None
    width = letter[0] - 1.7 * inch
    n = len(rows[0])
    col_w = [width / n] * n
    # Status table: give first col more room
    if n == 3:
        col_w = [width * 0.34, width * 0.28, width * 0.38]
    data = []
    for i, row in enumerate(rows):
        st = styles["table_head"] if i == 0 else styles["table_cell"]
        data.append([Paragraph(_inline(c), st) for c in row])
    tbl = Table(data, colWidths=col_w, repeatRows=1)
    style_cmds = [
        ("BACKGROUND", (0, 0), (-1, 0), NAVY),
        ("TEXTCOLOR", (0, 0), (-1, 0), white),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("LEFTPADDING", (0, 0), (-1, -1), 5),
        ("RIGHTPADDING", (0, 0), (-1, -1), 5),
        ("TOPPADDING", (0, 0), (-1, -1), 4),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
        ("GRID", (0, 0), (-1, -1), 0.3, RULE),
    ]
    for r in range(1, len(data)):
        if r % 2 == 0:
            style_cmds.append(("BACKGROUND", (0, r), (-1, r), ROW_ALT))
    tbl.setStyle(TableStyle(style_cmds))
    return tbl


def build(settings: dict | None = None) -> Path:
    s = settings or build_settings(parse_args([]))
    styles = _styles()
    text = s["input"].read_text(encoding="utf-8")
    # Drop the markdown title block; cover page carries it.
    lines = text.splitlines()
    story: list = []
    _cover(styles, story, s)

    i = 0
    if s["body_start"] == "after-rule":
        # skip the '# / ## / ### / **meta**' title block up to and including the first '---'
        while i < len(lines) and lines[i].strip() != "---":
            i += 1
        i += 1
    else:
        # skip leading H1 and bold meta lines until first ##
        while i < len(lines) and not lines[i].startswith("## "):
            i += 1

    para_buf: list[str] = []
    bullets: list[tuple[int, str]] = []
    table_buf: list[str] = []
    quote_buf: list[str] = []

    def flush_para() -> None:
        nonlocal para_buf
        if para_buf:
            story.append(Paragraph(_inline(" ".join(para_buf)), styles["body"]))
            para_buf = []

    def flush_quote() -> None:
        nonlocal quote_buf
        if quote_buf:
            story.append(Paragraph(_inline(" ".join(quote_buf)), styles["quote"]))
            quote_buf = []

    def flush_bullets() -> None:
        nonlocal bullets
        if not bullets:
            return
        items = []
        for level, b in bullets:
            if level == 0:
                items.append(ListItem(Paragraph(_inline(b), styles["bullet"]),
                                      leftIndent=12, bulletColor=GOLD))
            else:
                items.append(ListItem(Paragraph(_inline(b), styles["bullet_nested"]),
                                      leftIndent=28, bulletColor=GOLD, value="–"))
        story.append(
            ListFlowable(
                items,
                bulletType="bullet",
                start="•",
                leftIndent=18,
                bulletFontName="Times-Roman",
                bulletFontSize=10,
                spaceAfter=8,
            )
        )
        bullets = []

    def flush_table() -> None:
        nonlocal table_buf
        if table_buf:
            tbl = _parse_table(table_buf, styles)
            if tbl is not None:
                story.append(tbl)
                story.append(Spacer(1, 10))
            table_buf = []

    def flush_all() -> None:
        flush_para()
        flush_quote()
        flush_bullets()
        flush_table()

    while i < len(lines):
        raw = lines[i]
        line = raw.rstrip()
        i += 1

        if line.startswith("|"):
            flush_para()
            flush_quote()
            flush_bullets()
            table_buf.append(line)
            continue
        else:
            flush_table()

        if not line:
            flush_all()
            continue
        if line.strip() == "---":
            flush_all()
            continue
        if line.startswith("## "):
            flush_all()
            title = line[3:].strip()
            story.append(Paragraph(_inline(title), styles["h1"]))
            rule = HRFlowable(width="100%", thickness=0.6, color=GOLD, spaceAfter=8, spaceBefore=0)
            rule.keepWithNext = True
            story.append(rule)
            continue
        if line.startswith("### "):
            flush_all()
            story.append(Paragraph(_inline(line[4:].strip()), styles["h2"]))
            continue
        if line.startswith("#### "):
            # h4: a bold run-in paragraph (added 2026-09-02)
            flush_all()
            story.append(Paragraph("<b>" + _inline(line[5:].strip()) + "</b>", styles["body"]))
            continue
        if line.startswith(">"):
            # blockquote: indented italic paragraph (added 2026-09-02)
            flush_para()
            flush_bullets()
            quote_buf.append(line.lstrip(">").strip())
            continue
        stripped = line.lstrip(" ")
        indent = len(line) - len(stripped)
        if stripped.startswith("- "):
            flush_para()
            flush_quote()
            flush_table()
            bullets.append((1 if indent >= 2 else 0, stripped[2:].strip()))
            continue
        if re.match(r"^\d+\.\s", stripped):
            flush_para()
            flush_quote()
            flush_table()
            flush_bullets()
            story.append(Paragraph(_inline(stripped), styles["body"]))
            continue
        para_buf.append(line.strip())

    flush_all()

    doc = SimpleDocTemplate(
        str(s["output"]),
        pagesize=letter,
        leftMargin=0.85 * inch,
        rightMargin=0.85 * inch,
        topMargin=0.7 * inch,
        bottomMargin=0.55 * inch,
        title=s["doc_title"],
        author=s["author"],
        subject=s["subject"],
    )
    doc.mizoki_settings = s
    doc.build(story, onFirstPage=_on_first_page, onLaterPages=_header_footer)
    print(f"wrote {s['output']} ({s['output'].stat().st_size} bytes)")
    return s["output"]


if __name__ == "__main__":
    build(build_settings(parse_args()))

← All docsView source on GitHub →