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
419 lines
14 KiB
Python
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 = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
}
|
|
|
|
|
|
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())
|