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.
311 lines
10 KiB
Python
311 lines
10 KiB
Python
#!/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"<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:]))
|