File size: 7,843 Bytes
67ebcef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""Turns an answer written with a toolkit (kits/) into one self-contained file.

  three  : `import {...} from 'kit3d'` -> reads window.K3; the kit3d module is inserted before the page's module
           script and the import map gets 'three' and 'three/addons/' if they are missing;
  canvas : the kit2d script (global Kit) is inserted before the page's first script;
  html   : <link href="ui.css"> / <script src="ui.js"> are replaced by the inline kit (added if missing);
  svg    : the answer is a Python program using svgkit; it is run and the SVG it prints becomes the answer.
finalize(answer_text, cat) -> (text with one ```svg / ```html block, problem or None, source used for rendering)
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import xml.dom.minidom

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import sandbox  # noqa: E402
from grade import extract_code  # noqa: E402
from render import extract  # noqa: E402
from vis_autofix import fix_importmap  # noqa: E402

KITS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "kits")


def _read(name):
    return open(os.path.join(KITS, name), encoding="utf-8").read()


KIT3D, KIT2D, UICSS, UIJS = (_read(n) for n in ("kit3d.js", "kit2d.js", "ui.css", "ui.js"))
IMPORT_KIT3D = re.compile(r"""import\s+([^;'"]+?)\s+from\s+['"](?:\./)?kit3d(?:\.js)?['"]\s*;?""")
THREE_URL = "https://unpkg.com/three@0.160.0/build/three.module.js"
ADDONS_URL = "https://unpkg.com/three@0.160.0/examples/jsm/"


def _destructure(clause):
    """'{ a, b as c }' | '* as K' | 'K' | 'K, { a }' -> statements that read window.K3."""
    clause, out = clause.strip(), []
    m = re.fullmatch(r"\*\s+as\s+(\w+)", clause)
    if m:
        return f"const {m.group(1)} = window.K3;"
    d = re.match(r"(\w+)\s*(,|$)", clause)
    if d:
        out.append(f"const {d.group(1)} = window.K3;")
    b = re.search(r"\{(.*?)\}", clause, re.S)
    if b:
        names = []
        for part in b.group(1).split(","):
            part = part.strip()
            if part:
                a = re.fullmatch(r"(\w+)\s+as\s+(\w+)", part)
                names.append(f"{a.group(1)}: {a.group(2)}" if a else part)
        out.append("const { " + ", ".join(names) + " } = window.K3;")
    return " ".join(out)


def inline_three(src):
    if not IMPORT_KIT3D.search(src):
        return fix_importmap(src)
    src = IMPORT_KIT3D.sub(lambda m: _destructure(m.group(1)), src)
    src = fix_importmap(src)
    m = re.search(r"""<script\s+type=["']importmap["']\s*>(.*?)</script>""", src, re.S)
    if m:
        try:
            j = json.loads(m.group(1))
            imp = j.setdefault("imports", {})
            if "three" not in imp or "three/addons/" not in imp:
                imp.setdefault("three", THREE_URL)
                imp.setdefault("three/addons/", ADDONS_URL)
                src = src[:m.start(1)] + json.dumps(j) + src[m.end(1):]
        except ValueError:
            pass
    kit = '<script type="module">\n' + KIT3D + "\n</script>\n"
    m = re.search(r"""<script\s+type=["']module["']""", src)
    if not m:
        m = re.search(r"</body>", src, re.I)
    return src[:m.start()] + kit + src[m.start():] if m else src + kit


def inline_canvas(src):
    kit = "<script>\n" + KIT2D + "\n</script>\n"
    tag = re.search(r"""<script[^>]*\bsrc=["'](?:\./)?kit2d(?:\.js)?["'][^>]*>\s*</script>""", src, re.I)
    if tag:
        return src[:tag.start()] + kit + src[tag.end():]
    if "Kit." not in src or "window.Kit" in src:
        return src
    m = re.search(r"<script", src, re.I) or re.search(r"</body>", src, re.I)
    return src[:m.start()] + kit + src[m.start():] if m else src + kit


def inline_ui(src):
    css, js = "<style>\n" + UICSS + "\n</style>", "<script>\n" + UIJS + "\n</script>"
    link = re.search(r"""<link[^>]*href=["'](?:\./)?ui\.css["'][^>]*>""", src, re.I)
    if link:
        src = src[:link.start()] + css + src[link.end():]
    else:  # the kit comes first so the page's own <style> can override it
        m = re.search(r"<head[^>]*>", src, re.I)
        src = src[:m.end()] + "\n" + css + src[m.end():] if m else css + src
    tags = list(re.finditer(r"""<script[^>]*\bsrc=["'](?:\./)?ui\.js["'][^>]*>\s*</script>""", src, re.I))
    if tags:  # the first reference becomes the inline kit, repeated references are dropped
        for t in reversed(tags[1:]):
            src = src[:t.start()] + src[t.end():]
        return src[:tags[0].start()] + js + src[tags[0].end():]
    m = re.search(r"</body>", src, re.I)
    return src[:m.start()] + js + "\n" + src[m.start():] if m else src + js


def run_svg_program(code, timeout=20):
    """Runs a svgkit program in a fresh folder; returns (svg or None, problem or None)."""
    d = tempfile.mkdtemp(prefix="svgkit_")
    try:
        shutil.copy(os.path.join(KITS, "svgkit.py"), d)
        path = os.path.join(d, "draw.py")
        with open(path, "w", encoding="utf-8") as f:
            f.write(code)
        try:
            r = sandbox.run([sys.executable, "-E", "-s", path], input=b"", capture_output=True, timeout=timeout, cwd=d,
                            creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
        except subprocess.TimeoutExpired:
            return None, f"The program did not finish within {timeout} seconds."
        out, err = r.stdout.decode(errors="replace"), r.stderr.decode(errors="replace")
        if r.returncode != 0:
            return None, "The program failed:\n" + "\n".join(err.strip().splitlines()[-6:])
    finally:
        shutil.rmtree(d, ignore_errors=True)
    m = re.search(r"<svg.*</svg>", out, re.S)
    if not m:
        return None, "The program printed no <svg>...</svg> document (end it with print(s)). It printed:\n" + out[:300]
    try:
        xml.dom.minidom.parseString(m.group(0).encode("utf-8"))
    except Exception as e:
        return None, f"The printed SVG is not valid XML: {str(e)[:200]}"
    return m.group(0), None


def finalize(text, cat):
    """(final answer text, problem or None, source for rendering or None)."""
    if cat == "svg":
        code = extract_code(text)
        if code is None or "svgkit" not in code:
            src = extract(text, "svg")  # a direct SVG answer is still accepted
            if src:
                return f"```svg\n{src}\n```", None, src
            return text, "Your answer does not contain the Python program in a ```python code block.", None
        svg, problem = run_svg_program(code)
        return (f"```svg\n{svg}\n```" if svg else text), problem, svg
    src = extract(text, "html")
    if not src:
        return text, None, None
    new = {"three": inline_three, "canvas": inline_canvas, "html": inline_ui}[cat](src)
    return text.replace(src, new), None, new


CPLIB = _read("cplib.py").split('"""', 2)[2].lstrip()  # the library without its module docstring
CPLIB_IMPORT = re.compile(r"^[ \t]*(from\s+cplib\s+import\s+[^\n]+|import\s+cplib\b[^\n]*)$", re.M)


def inline_cplib(text):
    """Replaces `from cplib import ...` / `import cplib` in the answer's last ```python block by the library."""
    blocks = list(re.finditer(r"```(?:python|py|Python)?[ \t]*\n(.*?)```", text or "", re.S))
    if not blocks or not CPLIB_IMPORT.search(blocks[-1].group(1)):
        return text
    code = blocks[-1].group(1)
    module_style = re.search(r"^[ \t]*import\s+cplib\b", code, re.M)
    lib = CPLIB + ("\nimport types as _types\ncplib = _types.SimpleNamespace(**{k: v for k, v in dict(globals()).items() "
                   "if not k.startswith('_')})\n" if module_style else "")
    new = CPLIB_IMPORT.sub(lambda m: "# --- cplib (inlined) ---\n" + lib + "# --- end of cplib ---", code, count=1)
    new = CPLIB_IMPORT.sub("", new)
    s, e = blocks[-1].span(1)
    return text[:s] + new + text[e:]