#!/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:]))