"""Render a Markdown file to a polished PDF using reportlab + markdown-it-py. Supports the subset of Markdown used in our project docs: - ATX headings (#, ##, ###, ####) - Paragraphs with **bold**, *italic*, and `inline code` - Bullet and ordered lists (single level) - Fenced code blocks (```lang ... ```) - Tables (GFM pipe tables with header separator) - Horizontal rules (---) - Blockquotes (>) Usage: python scripts/render_md_to_pdf.py docs/ASSIGNMENT_WORKFLOW.md python scripts/render_md_to_pdf.py docs/ASSIGNMENT_WORKFLOW.md docs/ASSIGNMENT_WORKFLOW.pdf """ from __future__ import annotations import re import sys from pathlib import Path from html import escape as h from markdown_it import MarkdownIt from reportlab.lib import colors from reportlab.lib.enums import TA_LEFT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import cm from reportlab.platypus import ( HRFlowable, KeepTogether, ListFlowable, ListItem, PageBreak, Paragraph, Preformatted, SimpleDocTemplate, Spacer, Table, TableStyle, ) # ── Styles ───────────────────────────────────────────────────────────── INK = colors.HexColor("#0f172a") MUTED = colors.HexColor("#64748b") ACCENT = colors.HexColor("#2563eb") BORDER = colors.HexColor("#e2e8f0") SUBTLE = colors.HexColor("#f8fafc") CODE_BG = colors.HexColor("#f1f5f9") CODE_INK = colors.HexColor("#0f172a") def _styles(): base = getSampleStyleSheet() return { "h1": ParagraphStyle( "h1", parent=base["Title"], fontName="Helvetica-Bold", fontSize=22, leading=28, textColor=INK, spaceAfter=14, alignment=TA_LEFT, ), "h2": ParagraphStyle( "h2", parent=base["Heading2"], fontName="Helvetica-Bold", fontSize=15, leading=20, textColor=INK, spaceBefore=18, spaceAfter=8, ), "h3": ParagraphStyle( "h3", parent=base["Heading3"], fontName="Helvetica-Bold", fontSize=12, leading=16, textColor=ACCENT, spaceBefore=12, spaceAfter=4, ), "h4": ParagraphStyle( "h4", parent=base["Heading4"], fontName="Helvetica-Bold", fontSize=11, leading=14, textColor=INK, spaceBefore=8, spaceAfter=2, ), "body": ParagraphStyle( "body", parent=base["BodyText"], fontName="Helvetica", fontSize=9.5, leading=14, textColor=INK, spaceAfter=6, ), "muted": ParagraphStyle( "muted", parent=base["BodyText"], fontName="Helvetica-Oblique", fontSize=8.5, leading=12, textColor=MUTED, spaceAfter=6, ), "code": ParagraphStyle( "code", parent=base["Code"], fontName="Courier", fontSize=8, leading=11, textColor=CODE_INK, backColor=CODE_BG, borderPadding=8, leftIndent=0, rightIndent=0, spaceBefore=4, spaceAfter=10, ), "tableCell": ParagraphStyle( "tableCell", parent=base["BodyText"], fontName="Helvetica", fontSize=8.5, leading=11, textColor=INK, ), "tableHead": ParagraphStyle( "tableHead", parent=base["BodyText"], fontName="Helvetica-Bold", fontSize=8.5, leading=11, textColor=colors.white, ), "quote": ParagraphStyle( "quote", parent=base["BodyText"], fontName="Helvetica-Oblique", fontSize=9.5, leading=14, textColor=MUTED, leftIndent=12, borderColor=BORDER, borderWidth=0, backColor=SUBTLE, borderPadding=8, spaceAfter=8, ), } # ── Inline rendering: turn markdown-it inline tokens into reportlab markup ─ _RL_ESCAPE = { "&": "&", "<": "<", ">": ">", } def _esc(text: str) -> str: return "".join(_RL_ESCAPE.get(c, c) for c in text) def _render_inline(token) -> str: """Convert an `inline` markdown-it token's children into rl markup.""" if not getattr(token, "children", None): return _esc(token.content or "") out: list[str] = [] for child in token.children: t = child.type if t == "text": out.append(_esc(child.content)) elif t == "softbreak" or t == "hardbreak": out.append("
") elif t == "code_inline": out.append( f'' f' {_esc(child.content)} ' ) elif t == "strong_open": out.append("") elif t == "strong_close": out.append("") elif t == "em_open": out.append("") elif t == "em_close": out.append("") elif t == "s_open": out.append("") elif t == "s_close": out.append("") elif t == "link_open": href = "" for k, v in child.attrs.items() if isinstance(child.attrs, dict) else child.attrs: if k == "href": href = v out.append(f'') elif t == "link_close": out.append("") elif t == "image": alt = _esc(child.content or "image") out.append(f"[{alt}]") else: out.append(_esc(child.content or "")) return "".join(out) # ── Block rendering: walk markdown-it tokens and emit reportlab flowables ── def md_to_flowables(md_text: str, styles): md = MarkdownIt("commonmark", {"breaks": False, "html": False}).enable("table").enable("strikethrough") tokens = md.parse(md_text) flow: list = [] i = 0 n = len(tokens) while i < n: t = tokens[i] ttype = t.type if ttype == "heading_open": level = int(t.tag[1]) inline = tokens[i + 1] text = _render_inline(inline) style_key = {1: "h1", 2: "h2", 3: "h3"}.get(level, "h4") flow.append(Paragraph(text, styles[style_key])) if level == 1: flow.append(HRFlowable(width="100%", thickness=0.6, color=BORDER, spaceAfter=10)) i += 3 continue if ttype == "paragraph_open": inline = tokens[i + 1] text = _render_inline(inline) flow.append(Paragraph(text, styles["body"])) i += 3 continue if ttype == "bullet_list_open" or ttype == "ordered_list_open": ordered = ttype == "ordered_list_open" items, consumed = _parse_list_items(tokens, i, styles) flow.append( ListFlowable( items, bulletType="1" if ordered else "bullet", bulletFontName="Helvetica-Bold", bulletFontSize=9, leftIndent=18, bulletDedent=10, spaceAfter=8, ) ) i += consumed continue if ttype == "fence" or ttype == "code_block": content = t.content.rstrip("\n") flow.append(Preformatted(content, styles["code"])) i += 1 continue if ttype == "hr": flow.append(HRFlowable(width="100%", thickness=0.4, color=BORDER, spaceBefore=4, spaceAfter=10)) i += 1 continue if ttype == "blockquote_open": inner_md = _collect_blockquote(tokens, i) inner_flow = md_to_flowables(inner_md, styles) for fl in inner_flow: if isinstance(fl, Paragraph): fl.style = styles["quote"] flow.append(KeepTogether(inner_flow)) i = _skip_to(tokens, i, "blockquote_close") + 1 continue if ttype == "table_open": tbl, consumed = _parse_table(tokens, i, styles) flow.append(tbl) flow.append(Spacer(1, 8)) i += consumed continue i += 1 return flow def _parse_list_items(tokens, start, styles): """Parse bullet_list_open/ordered_list_open block, return (items, consumed).""" items: list = [] i = start + 1 depth = 1 while i < len(tokens) and depth > 0: t = tokens[i] if t.type in ("bullet_list_open", "ordered_list_open"): depth += 1 i += 1 continue if t.type in ("bullet_list_close", "ordered_list_close"): depth -= 1 i += 1 continue if t.type == "list_item_open" and depth == 1: j = i + 1 chunks: list = [] while j < len(tokens) and tokens[j].type != "list_item_close": if tokens[j].type == "paragraph_open": inline = tokens[j + 1] chunks.append(Paragraph(_render_inline(inline), styles["body"])) j += 3 else: j += 1 items.append(ListItem(chunks or [Paragraph("", styles["body"])], leftIndent=4)) i = j + 1 continue i += 1 return items, i - start def _skip_to(tokens, start, target_type): i = start while i < len(tokens) and tokens[i].type != target_type: i += 1 return i def _collect_blockquote(tokens, start) -> str: end = _skip_to(tokens, start, "blockquote_close") parts = [] for t in tokens[start + 1 : end]: if t.type == "inline": parts.append(t.content) return "\n\n".join(parts) def _parse_table(tokens, start, styles): headers: list[str] = [] rows: list[list[str]] = [] i = start + 1 while i < len(tokens) and tokens[i].type != "table_close": t = tokens[i] if t.type == "thead_open": i += 1 while tokens[i].type != "thead_close": if tokens[i].type == "tr_open": row: list[str] = [] j = i + 1 while tokens[j].type != "tr_close": if tokens[j].type in ("th_open", "td_open"): inline = tokens[j + 1] row.append(_render_inline(inline)) j += 3 else: j += 1 headers = row i = j + 1 else: i += 1 i += 1 continue if t.type == "tbody_open": i += 1 while tokens[i].type != "tbody_close": if tokens[i].type == "tr_open": row = [] j = i + 1 while tokens[j].type != "tr_close": if tokens[j].type in ("th_open", "td_open"): inline = tokens[j + 1] row.append(_render_inline(inline)) j += 3 else: j += 1 rows.append(row) i = j + 1 else: i += 1 i += 1 continue i += 1 consumed = i - start + 1 head_p = [Paragraph(h, styles["tableHead"]) for h in headers] body_p = [[Paragraph(c, styles["tableCell"]) for c in row] for row in rows] data = [head_p] + body_p page_w = A4[0] - 2 * 1.8 * cm n_cols = max(1, len(headers) or (len(rows[0]) if rows else 1)) col_w = [page_w / n_cols] * n_cols tbl = Table(data, colWidths=col_w, repeatRows=1) tbl.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), ACCENT), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("ALIGN", (0, 0), (-1, -1), "LEFT"), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, SUBTLE]), ("GRID", (0, 0), (-1, -1), 0.25, BORDER), ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ] ) ) return tbl, consumed # ── Page decoration ──────────────────────────────────────────────────── def _on_page(canvas, doc): canvas.saveState() canvas.setFont("Helvetica", 8) canvas.setFillColor(MUTED) page_num = canvas.getPageNumber() canvas.drawRightString(A4[0] - 1.8 * cm, 1.0 * cm, f"Page {page_num}") canvas.drawString(1.8 * cm, 1.0 * cm, "EnCoach LMS — Assignment Workflow") canvas.setStrokeColor(BORDER) canvas.setLineWidth(0.4) canvas.line(1.8 * cm, 1.4 * cm, A4[0] - 1.8 * cm, 1.4 * cm) canvas.restoreState() def render(md_path: Path, pdf_path: Path) -> None: md_text = md_path.read_text(encoding="utf-8") styles = _styles() flow = md_to_flowables(md_text, styles) doc = SimpleDocTemplate( str(pdf_path), pagesize=A4, leftMargin=1.8 * cm, rightMargin=1.8 * cm, topMargin=2.0 * cm, bottomMargin=2.0 * cm, title="EnCoach LMS — Assignment Workflow", author="EnCoach Platform", ) doc.build(flow, onFirstPage=_on_page, onLaterPages=_on_page) print(f"wrote {pdf_path}") def main() -> int: if len(sys.argv) < 2: print(__doc__) return 2 src = Path(sys.argv[1]).resolve() dst = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else src.with_suffix(".pdf") if not src.exists(): print(f"not found: {src}", file=sys.stderr) return 1 render(src, dst) return 0 if __name__ == "__main__": raise SystemExit(main())