#!/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("<", "<")
.replace(">", ">")
)
def _inline(text: str) -> str:
text = _esc(text)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"(?\1", 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", "
"), 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:]))