git » alan.git » master » tree

[master] / render_text.py

"""Render ALAN JSON to booklet and answer key."""
from __future__ import annotations

import argparse
import json
import os
from typing import Dict, Any
import subprocess
import shutil
import tempfile
import sys


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Render ALAN test to text.")
    parser.add_argument("--in", dest="in_path", required=True)
    parser.add_argument("--test-out", dest="test_out", default="test_booklet.txt")
    parser.add_argument("--key-out", dest="key_out", default="answer_key.txt")
    parser.add_argument("--test-pdf", dest="test_pdf", default=None, help="Optional PDF path for the booklet.")
    parser.add_argument("--key-pdf", dest="key_pdf", default=None, help="Optional PDF path for the answer key.")
    return parser.parse_args()


def render_booklet(data: Dict[str, Any]) -> str:
    lines = ["# Alan's Language Aptitude iNstrument (ALAN)", ""]
    meta = data.get("meta", {})
    info_bits = []
    if meta.get("seed") is not None:
        info_bits.append(f"seed={meta['seed']}")
    if meta.get("git_sha"):
        info_bits.append(f"sha={str(meta['git_sha'])[:7]}")
    params = meta.get("generation_params") or {}
    if params:
        joined = ", ".join(f"{k}={v}" for k, v in sorted(params.items()))
        info_bits.append(f"params: {joined}")
    if info_bits:
        lines.append(
            f"<div style=\"font-size: 0.7em; font-family: monospace; margin: 0 0 4px 0;\">"
            f"Test Version: {'; '.join(info_bits)}</div><br/>"
        )
    if meta.get("instructions"):
        lines.append(meta["instructions"])
        lines.append("")
    if meta.get("rules"):
        lines.append("## Grammar Cheat Sheet")
        for rule in meta["rules"]:
            lines.append(f"- {rule}")
        lines.append("")
    dict_data = meta.get("dictionary", {})
    if dict_data:
        lines.append("## Starter Dictionary")
        for title, group in [
            ("Nouns", dict_data.get("nouns", {})),
            ("Verbs", dict_data.get("verbs", {})),
            ("Adjectives", dict_data.get("adjectives", {})),
        ]:
            lines.append(f"### {title}")
            for eng, lang in group.items():
                lines.append(f"- {eng} = {lang}")
        lines.append("")

    for sec_idx, section in enumerate(data.get("sections", [])):
        lines.append("<div style=\"page-break-after: always;\"></div>")
        # start each section after a break, but keep the heading with content
        lines.append("<div style=\"page-break-before: always;\"></div>")
        lines.append(f"<h2 style=\"page-break-after: avoid;\">Section {sec_idx + 1}</h2>")
        lines.append("")
        for intro in section.get("intro_text", []):
            lines.append(intro)
            lines.append("")
        for q in section.get("questions", []):
            lines.append("")
            lines.append(f"**{q['number']}. {q['stem']}**")
            for opt in q.get("options", []):
                lines.append(f"- {opt['label']}) {opt['text']}")
            lines.append("")
        lines.append("")
    return "\n".join(lines)


def render_key(data: Dict[str, Any]) -> str:
    lines = ["# Answer Key", ""]
    meta = data.get("meta", {})
    info_bits = []
    if meta.get("seed") is not None:
        info_bits.append(f"seed={meta['seed']}")
    if meta.get("git_sha"):
        info_bits.append(f"sha={str(meta['git_sha'])[:7]}")
    params = meta.get("generation_params") or {}
    if params:
        joined = ", ".join(f"{k}={v}" for k, v in sorted(params.items()))
        info_bits.append(f"params: {joined}")
    if info_bits:
        lines.append(
            f"<div style=\"font-size: 0.7em; font-family: monospace; margin: 0 0 4px 0;\">"
            f"Test Version: {'; '.join(info_bits)}</div><br/>"
        )
    for section in data.get("sections", []):
        for q in section.get("questions", []):
            correct = next((o for o in q["options"] if o["is_correct"]), None)
            lines.append(f"**{q['number']}: {correct['label'] if correct else '?'}**")
            for opt in q["options"]:
                mark = "(correct)" if opt["is_correct"] else ""
                lines.append(f"- {opt['label']}) {opt['text']} {mark}")
            lines.append("")
    return "\n".join(lines)


def _tex_escape(s: str) -> str:
    """Escape LaTeX special characters."""
    replacements = {
        "\\": r"\textbackslash{}",
        "{": r"\{",
        "}": r"\}",
        "#": r"\#",
        "$": r"\$",
        "%": r"\%",
        "&": r"\&",
        "_": r"\_",
        "~": r"\textasciitilde{}",
        "^": r"\textasciicircum{}",
    }
    return "".join(replacements.get(ch, ch) for ch in s)


def _booklet_latex(data: Dict[str, Any]) -> str:
    meta = data.get("meta", {})
    info_bits = []
    if meta.get("seed") is not None:
        info_bits.append(f"seed={meta['seed']}")
    if meta.get("git_sha"):
        info_bits.append(f"sha={str(meta['git_sha'])[:7]}")
    params = meta.get("generation_params") or {}
    if params:
        joined = ", ".join(f"{k}={v}" for k, v in sorted(params.items()))
        info_bits.append(f"params: {joined}")
    header_line = ""
    if info_bits:
        safe_bits = [_tex_escape(bit) for bit in info_bits]
        header_line = f"{{\\small\\texttt{{Test Version: {'; '.join(safe_bits)}}}}}\\\\[6pt]"

    lines = [
        r"\documentclass[10pt]{article}",
        r"\usepackage[margin=0.65in]{geometry}",
        r"\usepackage[T1]{fontenc}",
        r"\usepackage[scaled=0.95]{helvet}",
        r"\usepackage{microtype}",
        r"\usepackage{enumitem}",
        r"\usepackage{needspace}",
        r"\renewcommand{\familydefault}{\sfdefault}",
        r"\setlength{\parskip}{4pt}",
        r"\setlength{\parindent}{0pt}",
        r"\setlist[itemize]{leftmargin=*,itemsep=2pt,topsep=2pt}",
        r"\usepackage{xcolor}",
        r"\usepackage{fancyhdr}",
        r"\pagestyle{fancy}",
        r"\fancyhf{}",
        r"\fancyhead[L]{ALAN}",
        r"\fancyhead[R]{"
        + (r"\texttt{" + "; ".join(safe_bits) + r"}" if header_line else "")
        + r"}",
        r"\fancyfoot[R]{\thepage}",
        r"\begin{document}",
        r"\begin{center}",
        r"{\Large\bfseries Alan's Language Aptitude iNstrument (ALAN)}\\[4pt]",
        (header_line if header_line else ""),
        r"\rule{0.9\linewidth}{0.4pt}",
        r"\end{center}",
    ]
    if meta.get("instructions"):
        lines.append(r"{\color{gray}" + _tex_escape(meta["instructions"]) + r"}")
    if meta.get("rules"):
        lines.append(r"\subsection*{\color{gray}Grammar Cheat Sheet}")
        lines.append(r"\begin{itemize}[leftmargin=1em]")
        for rule in meta["rules"]:
            lines.append(rf"\item {_tex_escape(rule)}")
        lines.append(r"\end{itemize}")
    dict_data = meta.get("dictionary", {})
    if dict_data:
        lines.append(r"\vspace{4pt}\rule{0.9\linewidth}{0.2pt}")
        lines.append(r"\subsection*{\color{gray}Starter Dictionary}")
        for title, group in [
            ("Nouns", dict_data.get("nouns", {})),
            ("Verbs", dict_data.get("verbs", {})),
            ("Adjectives", dict_data.get("adjectives", {})),
        ]:
            lines.append(rf"\paragraph*{{{_tex_escape(title)}}}")
            lines.append(r"\begin{itemize}[leftmargin=1em]")
            for eng, lang in group.items():
                lines.append(rf"\item {_tex_escape(eng)} = {_tex_escape(lang)}")
            lines.append(r"\end{itemize}")

    for sec_idx, section in enumerate(data.get("sections", [])):
        lines.append(r"\newpage")
        lines.append(rf"\section*{{Section {sec_idx + 1}}}")
        for intro in section.get("intro_text", []):
            lines.append(_tex_escape(intro))
        # breathing room before first question
        lines.append(r"")
        lines.append(r"")
        for q in section.get("questions", []):
            lines.append(r"\needspace{14\baselineskip}")
            lines.append(rf"\noindent\textbf{{{q['number']}.}} {_tex_escape(q['stem'])}")
            lines.append(r"\begin{itemize}[leftmargin=1.5em]")
            for opt in q.get("options", []):
                lines.append(rf"\item[{_tex_escape(opt['label'])})] {_tex_escape(opt['text'])}")
            lines.append(r"\end{itemize}")
            lines.append(r"\vspace{4pt}")
    lines.append(r"\end{document}")
    return "\n".join(lines)


def _key_latex(data: Dict[str, Any]) -> str:
    lines = [
        r"\documentclass[10pt]{article}",
        r"\usepackage[margin=0.65in]{geometry}",
        r"\usepackage[T1]{fontenc}",
        r"\usepackage[scaled=0.95]{helvet}",
        r"\usepackage{microtype}",
        r"\usepackage{enumitem}",
        r"\usepackage{needspace}",
        r"\usepackage{xcolor}",
        r"\usepackage{fancyhdr}",
        r"\renewcommand{\familydefault}{\sfdefault}",
        r"\setlength{\parskip}{4pt}",
        r"\setlength{\parindent}{0pt}",
        r"\setlist[itemize]{leftmargin=*,itemsep=2pt,topsep=2pt}",
        r"\pagestyle{fancy}",
        r"\fancyhf{}",
        r"\fancyhead[L]{ALAN}",
        r"\fancyfoot[R]{\thepage}",
        r"\begin{document}",
        r"\section*{Answer Key}",
    ]
    meta = data.get("meta", {})
    info_bits = []
    if meta.get("seed") is not None:
        info_bits.append(f"seed={meta['seed']}")
    if meta.get("git_sha"):
        info_bits.append(f"sha={str(meta['git_sha'])[:7]}")
    params = meta.get("generation_params") or {}
    if params:
        joined = ", ".join(f"{k}={v}" for k, v in sorted(params.items()))
        info_bits.append(f"params: {joined}")
    if info_bits:
        safe_bits = [_tex_escape(bit) for bit in info_bits]
        lines.append(f"{{\\small\\texttt{{Test Version: {'; '.join(safe_bits)}}}}}\\\\[6pt]")

    for section in data.get("sections", []):
        for q in section.get("questions", []):
            correct = next((o for o in q["options"] if o["is_correct"]), None)
            lines.append(rf"\noindent\textbf{{{q['number']}: {_tex_escape(correct['label'] if correct else '?')}}}")
            lines.append(r"\begin{itemize}[leftmargin=1.25em]")
            for opt in q["options"]:
                mark = " (correct)" if opt["is_correct"] else ""
                lines.append(rf"\item[{_tex_escape(opt['label'])})] {_tex_escape(opt['text'])}{_tex_escape(mark)}")
            lines.append(r"\end{itemize}")
    lines.append(r"\end{document}")
    return "\n".join(lines)


def _write_pdf(latex_source: str, pdf_path: str) -> None:
    """Render LaTeX source to PDF using pdflatex directly."""
    pdflatex = shutil.which("pdflatex")
    if not pdflatex:
        print("pdflatex required for PDF generation; skipping.", file=sys.stderr)
        return
    with tempfile.TemporaryDirectory() as tmpdir:
        tex_path = os.path.join(tmpdir, "doc.tex")
        with open(tex_path, "w", encoding="utf-8") as tf:
            tf.write(latex_source)
        try:
            subprocess.run(
                [pdflatex, "-interaction=nonstopmode", "-halt-on-error", "doc.tex"],
                cwd=tmpdir,
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            shutil.copyfile(os.path.join(tmpdir, "doc.pdf"), pdf_path)
        except subprocess.CalledProcessError:
            print("pdflatex failed to produce PDF.", file=sys.stderr)


def main() -> None:
    args = parse_args()
    with open(args.in_path, "r", encoding="utf-8") as f:
        data = json.load(f)
    run_dir = data.get("meta", {}).get("run_dir")
    if args.test_pdf is None and run_dir:
        args.test_pdf = os.path.join(run_dir, "test_booklet.pdf")
    if args.key_pdf is None and run_dir:
        args.key_pdf = os.path.join(run_dir, "answer_key.pdf")
    booklet_text = render_booklet(data)
    key_text = render_key(data)
    with open(args.test_out, "w", encoding="utf-8") as f:
        f.write(booklet_text)
    with open(args.key_out, "w", encoding="utf-8") as f:
        f.write(key_text)
    if args.test_pdf:
        _write_pdf(_booklet_latex(data), args.test_pdf)
    if args.key_pdf:
        _write_pdf(_key_latex(data), args.key_pdf)
    if run_dir and os.path.isdir(run_dir):
        for src in [args.test_out, args.key_out, args.test_pdf, args.key_pdf]:
            if not src:
                continue
            if not os.path.exists(src):
                continue
            dest = os.path.join(run_dir, os.path.basename(src))
            if os.path.abspath(dest) == os.path.abspath(src):
                continue
            try:
                shutil.copyfile(src, dest)
            except OSError:
                pass


if __name__ == "__main__":
    main()