master-zapier-plan-draft/research/zapier/scripts/md_to_guide_pdf.py
George Lambert b4150c8250 Milestone 0: import zappier billing, Verae middleware, and Zapier research
Compose-ready workspace: packages/zappier (rate card, portal, Stripe),
packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier
(CLI app), vendor/zapier-platform, and research/zapier vendor corpus.

Gate 0 structure checks pass. Product code and research are not yet wired.
2026-09-09 02:37:36 -04:00

311 lines
10 KiB
Python
Raw Permalink 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
"""Render a Markdown guide to a letter PDF (headings, tables, code, bullets).
Usage:
python3 scripts/md_to_guide_pdf.py INPUT.md OUTPUT.pdf [title]
python3 scripts/md_to_guide_pdf.py --concat OUT.pdf TITLE file1.md file2.md ...
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
PageBreak,
Paragraph,
Preformatted,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
NAVY = colors.HexColor("#0F2744")
TEAL = colors.HexColor("#1A6B6B")
SLATE = colors.HexColor("#334155")
RULE = colors.HexColor("#CBD5E1")
ROW = colors.HexColor("#F1F5F9")
CODE_BG = colors.HexColor("#F8FAFC")
USABLE = letter[0] - 1.4 * inch
def _styles():
base = getSampleStyleSheet()
return {
"cover_kicker": ParagraphStyle(
"k", parent=base["Normal"], fontName="Helvetica", fontSize=10,
textColor=TEAL, alignment=TA_CENTER, spaceAfter=8,
),
"cover_title": ParagraphStyle(
"t", parent=base["Title"], fontName="Helvetica-Bold", fontSize=20,
leading=26, textColor=NAVY, alignment=TA_CENTER, spaceAfter=10,
),
"h1": ParagraphStyle(
"h1", parent=base["Heading1"], fontName="Helvetica-Bold",
fontSize=13.5, leading=17, textColor=NAVY, spaceBefore=12, spaceAfter=6,
),
"h2": ParagraphStyle(
"h2", parent=base["Heading2"], fontName="Helvetica-Bold",
fontSize=11.2, leading=14.5, textColor=TEAL, spaceBefore=9, spaceAfter=4,
),
"h3": ParagraphStyle(
"h3", parent=base["Heading3"], fontName="Helvetica-Bold",
fontSize=10, leading=13, textColor=NAVY, spaceBefore=7, spaceAfter=3,
),
"body": ParagraphStyle(
"b", parent=base["Normal"], fontName="Helvetica", fontSize=9.1,
leading=12.4, textColor=SLATE, alignment=TA_LEFT, spaceAfter=5,
),
"bullet": ParagraphStyle(
"bu", parent=base["Normal"], fontName="Helvetica", fontSize=9.1,
leading=12.2, textColor=SLATE, leftIndent=14, firstLineIndent=-10, spaceAfter=2,
),
"cell": ParagraphStyle(
"c", parent=base["Normal"], fontName="Helvetica", fontSize=7.4,
leading=10, textColor=SLATE,
),
"cell_h": ParagraphStyle(
"ch", parent=base["Normal"], fontName="Helvetica-Bold", fontSize=7.4,
leading=10, textColor=colors.white,
),
"code": ParagraphStyle(
"co", parent=base["Code"], fontName="Courier", fontSize=7.2,
leading=9.6, textColor=NAVY, backColor=CODE_BG, leftIndent=4,
rightIndent=4, spaceBefore=2, spaceAfter=6,
),
"caption": ParagraphStyle(
"ca", parent=base["Normal"], fontName="Helvetica-Oblique", fontSize=8,
leading=11, textColor=colors.HexColor("#64748B"), spaceAfter=8, alignment=TA_CENTER,
),
}
S = _styles()
def _esc(text: str) -> str:
return (
text.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def _inline(text: str) -> str:
text = _esc(text)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
text = re.sub(r"`([^`]+)`", r"<font face='Courier' size='8'>\1</font>", text)
text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<i>\1</i>", text)
return text.replace("|", "/")
def _ascii_code(text: str) -> str:
repl = {
"": "|", "": "-", "": "+", "": "+", "": "+", "": "+",
"": "+", "": "+", "": "+", "": "+", "": "+",
"": ">", "": "v", "": "^", "": "<",
"": "->", "": "<-", "": "<->", "": "=>",
"": "--", "": "-", "": "...", "×": "x",
}
for a, b in repl.items():
text = text.replace(a, b)
return text
def _table(header: list[str], rows: list[list[str]]):
n = max(1, len(header))
# weight first column a bit if many cols
if n == 1:
widths = [USABLE]
elif n == 2:
widths = [2.2 * inch, USABLE - 2.2 * inch]
else:
first = 1.5 * inch
rest = (USABLE - first) / (n - 1)
widths = [first] + [rest] * (n - 1)
if n >= 4:
widths = [USABLE / n] * n
data = [[Paragraph(_inline(h), S["cell_h"]) for h in header]]
for row in rows:
padded = (row + [""] * n)[:n]
data.append([Paragraph(_inline(c), S["cell"]) for c in padded])
t = Table(data, colWidths=widths, repeatRows=1)
cmds = [
("BACKGROUND", (0, 0), (-1, 0), NAVY),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("GRID", (0, 0), (-1, -1), 0.3, RULE),
("LINEBELOW", (0, 0), (-1, 0), 1, TEAL),
]
for i in range(1, len(data)):
if i % 2 == 0:
cmds.append(("BACKGROUND", (0, i), (-1, i), ROW))
t.setStyle(TableStyle(cmds))
return t
def _split_row(line: str) -> list[str]:
line = line.strip().strip("|")
return [c.strip() for c in line.split("|")]
def md_to_flowables(md: str, skip_first_h1: bool = False) -> list:
lines = md.replace("\r\n", "\n").split("\n")
out = []
i = 0
skipped = False
while i < len(lines):
line = lines[i]
if line.startswith("```"):
buf = []
i += 1
while i < len(lines) and not lines[i].startswith("```"):
buf.append(lines[i])
i += 1
i += 1
block = _ascii_code("\n".join(buf))
out.append(Preformatted(block + "\n", S["code"]))
continue
if re.match(r"^\s*\|", line) and i + 1 < len(lines) and re.search(r"\|\s*-+", lines[i + 1]):
header = _split_row(line)
i += 2
rows = []
while i < len(lines) and re.match(r"^\s*\|", lines[i]):
rows.append(_split_row(lines[i]))
i += 1
out.append(_table(header, rows))
out.append(Spacer(1, 6))
continue
if line.strip() == "---":
out.append(Spacer(1, 8))
i += 1
continue
if line.startswith("# "):
if skip_first_h1 and not skipped:
skipped = True
i += 1
continue
out.append(Paragraph(_inline(line[2:].strip()), S["h1"]))
i += 1
continue
if line.startswith("## "):
out.append(Paragraph(_inline(line[3:].strip()), S["h2"]))
i += 1
continue
if line.startswith("### "):
out.append(Paragraph(_inline(line[4:].strip()), S["h3"]))
i += 1
continue
m = re.match(r"^(\s*)[-*]\s+(.*)$", line)
if m:
out.append(Paragraph("" + _inline(m.group(2)), S["bullet"]))
i += 1
continue
m = re.match(r"^(\s*)\d+\.\s+(.*)$", line)
if m:
out.append(Paragraph("" + _inline(m.group(2)), S["bullet"]))
i += 1
continue
if not line.strip():
i += 1
continue
out.append(Paragraph(_inline(line.strip()), S["body"]))
i += 1
return out
def header_footer_factory(kicker: str):
def header_footer(canvas, doc):
canvas.saveState()
w, h = letter
canvas.setFillColor(NAVY)
canvas.rect(0, h - 28, w, 28, fill=1, stroke=0)
canvas.setFillColor(colors.white)
canvas.setFont("Helvetica", 8)
canvas.drawString(0.7 * inch, h - 18, "Verae Time x Zapier")
canvas.drawRightString(w - 0.7 * inch, h - 18, kicker[:70])
canvas.setFillColor(TEAL)
canvas.rect(0, 0, w, 22, fill=1, stroke=0)
canvas.setFillColor(colors.white)
canvas.setFont("Helvetica", 8)
canvas.drawString(0.7 * inch, 8, "Internal · 18 August 2026")
canvas.drawRightString(w - 0.7 * inch, 8, str(doc.page))
canvas.restoreState()
return header_footer
def cover_footer(canvas, doc):
header_footer_factory("")(canvas, doc)
def build_pdf(parts: list[tuple[str, str]], dest: Path, title: str, subtitle: str):
dest.parent.mkdir(parents=True, exist_ok=True)
story = [
Spacer(1, 1.7 * inch),
Paragraph("VERAE TIME · ZAPIER WORKSPACE", S["cover_kicker"]),
Paragraph(_esc(title).replace("\n", "<br/>"), S["cover_title"]),
Paragraph(_esc(subtitle), S["caption"]),
PageBreak(),
]
for idx, (label, md) in enumerate(parts):
if idx:
story.append(PageBreak())
if label:
story.append(Paragraph(_inline(label), S["h1"]))
story.extend(md_to_flowables(md, skip_first_h1=bool(label)))
doc = SimpleDocTemplate(
str(dest),
pagesize=letter,
leftMargin=0.7 * inch,
rightMargin=0.7 * inch,
topMargin=0.55 * inch,
bottomMargin=0.45 * inch,
title=title,
author="Verae / Zapier research workspace",
subject=subtitle,
)
hf = header_footer_factory(title)
doc.build(story, onFirstPage=cover_footer, onLaterPages=hf)
print(f"wrote {dest} ({dest.stat().st_size} bytes)")
def main(argv: list[str]) -> int:
if len(argv) >= 2 and argv[0] == "--concat":
dest = Path(argv[1])
title = argv[2]
files = [Path(p) for p in argv[3:]]
parts = [(p.name, p.read_text(encoding="utf-8")) for p in files]
build_pdf(parts, dest, title, "Combined from " + ", ".join(p.name for p in files))
return 0
if len(argv) < 2:
print(__doc__)
return 2
src = Path(argv[0])
dest = Path(argv[1])
title = argv[2] if len(argv) > 2 else src.stem.replace("-", " ").title()
build_pdf([(None, src.read_text(encoding="utf-8"))], dest, title, str(src))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))