Files
full_encoach_platform/scripts/render_md_to_pdf.py
Yamen Ahmad 3b62075d7e feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches
Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
  manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
  page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
  sources and persists grounded_on_json metadata
- security: access rules for new models

Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints

Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
  enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
  hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)

Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
  course-plan RAG pipeline

Made-with: Cursor
2026-04-28 00:17:51 +04:00

419 lines
14 KiB
Python

"""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 = {
"&": "&",
"<": "&lt;",
">": "&gt;",
}
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("<br/>")
elif t == "code_inline":
out.append(
f'<font name="Courier" size="8.5" backColor="#f1f5f9">'
f' {_esc(child.content)} </font>'
)
elif t == "strong_open":
out.append("<b>")
elif t == "strong_close":
out.append("</b>")
elif t == "em_open":
out.append("<i>")
elif t == "em_close":
out.append("</i>")
elif t == "s_open":
out.append("<strike>")
elif t == "s_close":
out.append("</strike>")
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'<a href="{_esc(href)}" color="#2563eb">')
elif t == "link_close":
out.append("</a>")
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())