#!/usr/bin/env python3
"""
Shared Curriculum Rendering Engine
Renders JSON outlines into PDFs via Jinja2 templates + WeasyPrint.
Subject-agnostic — each subject provides its own Jinja2 template.
Usage:
from render import render_outline, render_week, render_all
render_outline(outline_path, template_dir, output_dir)
"""
import json
import os
import re
import sys
import glob
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
from weasyprint import HTML
# ─── Jinja2 Setup ──────────────────────────────────────────────────────────
def create_env(template_dir: str) -> Environment:
"""Create a Jinja2 environment with the given template directory."""
env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=False, # We control the content
keep_trailing_newline=True,
)
# Helpful filters
env.filters["enumerate"] = enumerate
env.filters["split"] = lambda s, sep=",": s.split(sep)
# Convert underscore blanks (______) to proper answer spans
env.filters["blanks"] = lambda s: re.sub(r'_+', '<span class="fill-blank-line"></span>', s)
# Safe dict access for keys that conflict with dict methods (items, keys, values)
env.globals["val"] = lambda d, k, default="": d[k] if isinstance(d, dict) and k in d else (getattr(d, k, default) if hasattr(d, k) else default)
env.filters["capitalize"] = str.capitalize
env.filters["upper"] = str.upper
env.filters["lower"] = str.lower
env.filters["length"] = len
env.filters["first"] = lambda l: l[0] if l else ""
env.filters["last"] = lambda l: l[-1] if l else ""
env.filters["chunk"] = lambda l, n: [l[i:i + n] for i in range(0, len(l), n)]
return env
# ─── Core Rendering ────────────────────────────────────────────────────────
def render_outline(
outline_path: str,
template_dir: str,
output_dir: str,
template_name: str = None,
verbose: bool = False,
) -> str:
"""
Render a single outline JSON into an HTML file, then PDF.
Args:
outline_path: Path to outline JSON file
template_dir: Directory containing Jinja2 template(s)
output_dir: Directory to write HTML and PDF to
template_name: Template filename (e.g. 'lesson.html'). If None, picks the first .html template.
verbose: Print progress info
Returns:
Path to generated PDF
"""
outline_path = Path(outline_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Load outline
with open(outline_path) as f:
outline = json.load(f)
# Resolve template
env = create_env(template_dir)
if template_name is None:
templates = list(Path(template_dir).glob("*.html"))
if not templates:
raise FileNotFoundError(f"No .html templates found in {template_dir}")
template_name = templates[0].name
try:
template = env.get_template(template_name)
except TemplateNotFound:
raise FileNotFoundError(f"Template '{template_name}' not found in {template_dir}")
# Render HTML
html_content = template.render(**outline)
# Write HTML
stem = outline_path.stem
html_path = output_dir / f"{stem}.html"
with open(html_path, "w") as f:
f.write(html_content)
if verbose:
print(f" HTML → {html_path} ({len(html_content)} chars)")
# Render PDF
pdf_path = output_dir / f"{stem}.pdf"
HTML(string=html_content).write_pdf(str(pdf_path))
if verbose:
print(f" PDF → {pdf_path}")
return str(pdf_path)
def render_week(
outlines_dir: str,
template_dir: str,
output_base: str,
week_num: int,
template_name: str = None,
days: list = None,
verbose: bool = False,
) -> list:
"""
Render all days in a week.
Args:
outlines_dir: Directory containing outline JSON files
template_dir: Directory containing Jinja2 template(s)
output_base: Base output directory (week subdirs created here)
week_num: Week number (e.g. 1)
template_name: Template filename
days: List of day names to render (e.g. ['Monday', 'Wednesday', 'Friday'])
verbose: Print progress info
Returns:
List of generated PDF paths
"""
outlines_dir = Path(outlines_dir)
output_base = Path(output_base)
week_dir = output_base / f"Week{week_num:02d}"
week_dir.mkdir(parents=True, exist_ok=True)
if days is None:
days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
pdfs = []
for day in days:
pattern = f"Week{week_num:02d}_{day}*.json"
matches = sorted(outlines_dir.glob(pattern))
if not matches:
if verbose:
print(f" SKIP: No outline for Week {week_num} {day}")
continue
for outline_path in matches:
pdf = render_outline(
outline_path,
template_dir,
week_dir,
template_name=template_name,
verbose=verbose,
)
pdfs.append(pdf)
return pdfs
def render_all(
outlines_dir: str,
template_dir: str,
output_base: str,
total_weeks: int = 32,
template_name: str = None,
days: list = None,
verbose: bool = False,
) -> list:
"""
Render all weeks in a course.
Returns:
List of all generated PDF paths
"""
all_pdfs = []
for week in range(1, total_weeks + 1):
pdfs = render_week(
outlines_dir,
template_dir,
output_base,
week,
template_name=template_name,
days=days,
verbose=verbose,
)
all_pdfs.extend(pdfs)
if verbose:
print(f" Week {week}: {len(pdfs)} PDF(s)")
return all_pdfs
# ─── Validation ────────────────────────────────────────────────────────────
def validate_outline(outline_path: str) -> dict:
"""
Validate an outline JSON has required fields.
Returns dict with 'valid' bool and 'errors' list.
"""
with open(outline_path) as f:
outline = json.load(f)
errors = []
# Required fields
required = ["week", "day", "topic"]
for field in required:
if field not in outline:
errors.append(f"Missing required field: {field}")
# Check pages
if "pages" in outline:
if not isinstance(outline["pages"], list) or len(outline["pages"]) == 0:
errors.append("'pages' must be a non-empty list")
elif "reading" not in outline and "activities" not in outline:
errors.append("Outline must have 'pages', 'reading', or 'activities'")
return {"valid": len(errors) == 0, "errors": errors}
if __name__ == "__main__":
# Quick test
import argparse
parser = argparse.ArgumentParser(description="Render curriculum outlines to PDF")
parser.add_argument("outline", help="Path to outline JSON or week directory")
parser.add_argument("-t", "--template-dir", required=True, help="Template directory")
parser.add_argument("-o", "--output-dir", required=True, help="Output directory")
parser.add_argument("--template", help="Specific template file name")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
outline_path = Path(args.outline)
if outline_path.is_dir():
# Render all outlines in directory
for json_file in sorted(outline_path.glob("*.json")):
render_outline(
json_file,
args.template_dir,
args.output_dir,
template_name=args.template,
verbose=args.verbose,
)
else:
render_outline(
outline_path,
args.template_dir,
args.output_dir,
template_name=args.template,
verbose=args.verbose,
)