"""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()