QuizGenerator 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- QuizGenerator/README.md +5 -0
- QuizGenerator/__init__.py +27 -0
- QuizGenerator/__main__.py +7 -0
- QuizGenerator/canvas/__init__.py +13 -0
- QuizGenerator/canvas/canvas_interface.py +627 -0
- QuizGenerator/canvas/classes.py +235 -0
- QuizGenerator/constants.py +149 -0
- QuizGenerator/contentast.py +1955 -0
- QuizGenerator/generate.py +253 -0
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/misc.py +579 -0
- QuizGenerator/mixins.py +548 -0
- QuizGenerator/performance.py +202 -0
- QuizGenerator/premade_questions/__init__.py +0 -0
- QuizGenerator/premade_questions/basic.py +103 -0
- QuizGenerator/premade_questions/cst334/__init__.py +1 -0
- QuizGenerator/premade_questions/cst334/languages.py +391 -0
- QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
- QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
- QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
- QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
- QuizGenerator/premade_questions/cst334/process.py +648 -0
- QuizGenerator/premade_questions/cst463/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
- QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
- QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
- QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
- QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
- QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
- QuizGenerator/premade_questions/cst463/models/text.py +203 -0
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
- QuizGenerator/qrcode_generator.py +293 -0
- QuizGenerator/question.py +715 -0
- QuizGenerator/quiz.py +467 -0
- QuizGenerator/regenerate.py +472 -0
- QuizGenerator/typst_utils.py +113 -0
- quizgenerator-0.4.2.dist-info/METADATA +265 -0
- quizgenerator-0.4.2.dist-info/RECORD +52 -0
- quizgenerator-0.4.2.dist-info/WHEEL +4 -0
- quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
- quizgenerator-0.4.2.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1955 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import enum
|
|
5
|
+
import re
|
|
6
|
+
import textwrap
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from typing import List, Callable
|
|
9
|
+
|
|
10
|
+
import numpy
|
|
11
|
+
import pypandoc
|
|
12
|
+
import markdown
|
|
13
|
+
|
|
14
|
+
# from QuizGenerator.misc import Answer
|
|
15
|
+
|
|
16
|
+
from QuizGenerator.qrcode_generator import QuestionQRCode
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
class ContentAST:
|
|
23
|
+
"""
|
|
24
|
+
Content Abstract Syntax Tree - The core content system for quiz generation.
|
|
25
|
+
|
|
26
|
+
IMPORTANT: ALWAYS use ContentAST elements for ALL content generation.
|
|
27
|
+
Never create custom LaTeX, HTML, or Markdown strings manually.
|
|
28
|
+
|
|
29
|
+
This system provides cross-format compatibility between:
|
|
30
|
+
- LaTeX/PDF output for printed exams
|
|
31
|
+
- HTML/Canvas output for online quizzes
|
|
32
|
+
- Markdown for documentation
|
|
33
|
+
|
|
34
|
+
Key Components:
|
|
35
|
+
- ContentAST.Section: Container for groups of elements (use for get_body/get_explanation)
|
|
36
|
+
- ContentAST.Paragraph: Text blocks that automatically handle spacing
|
|
37
|
+
- ContentAST.Equation: Mathematical equations with proper LaTeX/MathJax rendering
|
|
38
|
+
- ContentAST.Matrix: Mathematical matrices (DON'T use manual \\begin{bmatrix})
|
|
39
|
+
- ContentAST.Table: Data tables with proper formatting
|
|
40
|
+
- ContentAST.Answer: Answer input fields
|
|
41
|
+
- ContentAST.OnlyHtml/OnlyLatex: Platform-specific content
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
# Good - uses ContentAST
|
|
45
|
+
body = ContentAST.Section()
|
|
46
|
+
body.add_element(ContentAST.Paragraph(["Calculate the matrix:"]))
|
|
47
|
+
matrix_data = [[1, 2], [3, 4]]
|
|
48
|
+
body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="b"))
|
|
49
|
+
|
|
50
|
+
# Bad - manual LaTeX (inconsistent, error-prone)
|
|
51
|
+
body.add_element(ContentAST.Text("\\\\begin{bmatrix} 1 & 2 \\\\\\\\ 3 & 4 \\\\end{bmatrix}"))
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
class OutputFormat(enum.StrEnum):
|
|
55
|
+
HTML = "html"
|
|
56
|
+
TYPST = "typst"
|
|
57
|
+
LATEX = "latex"
|
|
58
|
+
MARKDOWN = "markdown"
|
|
59
|
+
|
|
60
|
+
class Element(abc.ABC):
|
|
61
|
+
"""
|
|
62
|
+
Base class for all ContentAST elements providing cross-format rendering.
|
|
63
|
+
|
|
64
|
+
This is the foundation class that all ContentAST elements inherit from.
|
|
65
|
+
It provides the core rendering infrastructure that enables consistent
|
|
66
|
+
output across LaTeX/PDF, HTML/Canvas, and Markdown formats.
|
|
67
|
+
|
|
68
|
+
Key Features:
|
|
69
|
+
- Cross-format rendering (markdown, html, latex)
|
|
70
|
+
- Automatic format conversion via pypandoc
|
|
71
|
+
- Element composition and nesting
|
|
72
|
+
- Consistent spacing and formatting
|
|
73
|
+
|
|
74
|
+
When to inherit from Element:
|
|
75
|
+
- Creating new content types that need multi-format output
|
|
76
|
+
- Building container elements that hold other elements
|
|
77
|
+
- Implementing custom rendering logic for specific content types
|
|
78
|
+
|
|
79
|
+
Example usage:
|
|
80
|
+
# Most elements inherit from this automatically
|
|
81
|
+
section = ContentAST.Section()
|
|
82
|
+
section.add_element(ContentAST.Text("Hello world"))
|
|
83
|
+
section.add_element(ContentAST.Equation("x = 5"))
|
|
84
|
+
|
|
85
|
+
# Renders to any format
|
|
86
|
+
latex_output = section.render("latex")
|
|
87
|
+
html_output = section.render("html")
|
|
88
|
+
"""
|
|
89
|
+
def __init__(self, elements=None, add_spacing_before=False):
|
|
90
|
+
pass
|
|
91
|
+
# self.elements : List[ContentAST.Element] = [
|
|
92
|
+
# e if isinstance(e, ContentAST.Element) else ContentAST.Text(e)
|
|
93
|
+
# for e in (elements if elements else [])
|
|
94
|
+
# ]
|
|
95
|
+
# self.add_spacing_before = add_spacing_before
|
|
96
|
+
|
|
97
|
+
def __str__(self):
|
|
98
|
+
return self.render_markdown()
|
|
99
|
+
|
|
100
|
+
def render(self, output_format : ContentAST.OutputFormat, **kwargs) -> str:
|
|
101
|
+
# Render using the appropriate method, if it exists
|
|
102
|
+
method_name = f"render_{output_format}"
|
|
103
|
+
if hasattr(self, method_name):
|
|
104
|
+
return getattr(self, method_name)(**kwargs)
|
|
105
|
+
|
|
106
|
+
return self.render_markdown(**kwargs) # Fallback to markdown
|
|
107
|
+
|
|
108
|
+
@abc.abstractmethod
|
|
109
|
+
def render_markdown(self, **kwargs):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
@abc.abstractmethod
|
|
113
|
+
def render_html(self, **kwargs):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
@abc.abstractmethod
|
|
117
|
+
def render_latex(self, **kwargs):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
@abc.abstractmethod
|
|
121
|
+
def render_typst(self, **kwargs):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def is_mergeable(self, other: ContentAST.Element):
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
class Container(Element):
|
|
128
|
+
"""Elements that contain other elements. Generally are formatting of larger pieces."""
|
|
129
|
+
def __init__(self, elements=None, **kwargs):
|
|
130
|
+
super().__init__(**kwargs)
|
|
131
|
+
self.elements : List[ContentAST.Element] = elements if elements is not None else []
|
|
132
|
+
|
|
133
|
+
def add_element(self, element):
|
|
134
|
+
self.elements.append(element)
|
|
135
|
+
|
|
136
|
+
def add_elements(self, elements):
|
|
137
|
+
self.elements.extend(elements)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def render_element(element, output_format: ContentAST.OutputFormat, **kwargs):
|
|
141
|
+
if isinstance(element, ContentAST.Element):
|
|
142
|
+
return element.render(output_format, **kwargs)
|
|
143
|
+
log.warning(f"Element ({element}) is not ContentAST.Element. Defaulting to forcing to a string.")
|
|
144
|
+
return f"{element}"
|
|
145
|
+
|
|
146
|
+
def render_markdown(self, **kwargs):
|
|
147
|
+
return " ".join([
|
|
148
|
+
self.render_element(element, output_format=ContentAST.OutputFormat.MARKDOWN, **kwargs)
|
|
149
|
+
for element in self.elements
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
def render_html(self, **kwargs):
|
|
153
|
+
for element in self.elements:
|
|
154
|
+
log.debug(f"element: {element}")
|
|
155
|
+
return " ".join([
|
|
156
|
+
self.render_element(element, output_format=ContentAST.OutputFormat.HTML, **kwargs)
|
|
157
|
+
for element in self.elements
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
def render_latex(self, **kwargs):
|
|
161
|
+
return "".join([
|
|
162
|
+
self.render_element(element, output_format=ContentAST.OutputFormat.LATEX, **kwargs)
|
|
163
|
+
for element in self.elements
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
latex = "".join(element.render("latex", **kwargs) for element in self.elements)
|
|
167
|
+
return f"{'\n\n\\vspace{0.5cm}' if self.add_spacing_before else ''}{latex}"
|
|
168
|
+
|
|
169
|
+
def render_typst(self, **kwargs):
|
|
170
|
+
|
|
171
|
+
return " ".join([
|
|
172
|
+
self.render_element(element, output_format=ContentAST.OutputFormat.TYPST, **kwargs)
|
|
173
|
+
for element in self.elements
|
|
174
|
+
])
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
Default Typst rendering using markdown → typst conversion via pandoc.
|
|
178
|
+
|
|
179
|
+
This provides instant Typst support for all ContentAST elements without
|
|
180
|
+
needing explicit implementations. Override this method in subclasses
|
|
181
|
+
when pandoc conversion quality is insufficient or Typst-specific
|
|
182
|
+
features are needed.
|
|
183
|
+
"""
|
|
184
|
+
# Render to markdown first
|
|
185
|
+
markdown_content = self.render_markdown(**kwargs)
|
|
186
|
+
|
|
187
|
+
# Convert markdown to Typst via pandoc
|
|
188
|
+
typst_content = self.convert_markdown(markdown_content, 'typst')
|
|
189
|
+
|
|
190
|
+
# Add spacing if needed (Typst equivalent of \vspace)
|
|
191
|
+
if self.add_spacing_before:
|
|
192
|
+
return f"\n{typst_content}"
|
|
193
|
+
|
|
194
|
+
return typst_content if typst_content else markdown_content
|
|
195
|
+
|
|
196
|
+
class Leaf(Element):
|
|
197
|
+
"""Elements that are just themselves."""
|
|
198
|
+
def __init__(self, content : str, **kwargs):
|
|
199
|
+
super().__init__(**kwargs)
|
|
200
|
+
self.content = content
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def convert_markdown(str_to_convert, output_format : ContentAST.OutputFormat):
|
|
204
|
+
try:
|
|
205
|
+
match output_format:
|
|
206
|
+
|
|
207
|
+
case ContentAST.OutputFormat.MARKDOWN:
|
|
208
|
+
return str_to_convert
|
|
209
|
+
|
|
210
|
+
case ContentAST.OutputFormat.HTML:
|
|
211
|
+
html_output = markdown.markdown(str_to_convert)
|
|
212
|
+
|
|
213
|
+
# Strip surrounding <p> tags so we can control paragraphs
|
|
214
|
+
if html_output.startswith("<p>") and html_output.endswith("</p>"):
|
|
215
|
+
html_output = html_output[3:-4]
|
|
216
|
+
|
|
217
|
+
return html_output.strip()
|
|
218
|
+
|
|
219
|
+
case _:
|
|
220
|
+
output = pypandoc.convert_text(
|
|
221
|
+
str_to_convert,
|
|
222
|
+
output_format,
|
|
223
|
+
format='md',
|
|
224
|
+
extra_args=["-M2GB", "+RTS", "-K64m", "-RTS"]
|
|
225
|
+
)
|
|
226
|
+
return output
|
|
227
|
+
except Exception as e:
|
|
228
|
+
log.warning(f"Specified conversion failed. Defaulting to markdown")
|
|
229
|
+
log.warning(e)
|
|
230
|
+
|
|
231
|
+
return str(str_to_convert)
|
|
232
|
+
|
|
233
|
+
def render_markdown(self, **kwargs):
|
|
234
|
+
return self.convert_markdown(self.content, ContentAST.OutputFormat.MARKDOWN)
|
|
235
|
+
|
|
236
|
+
def render_html(self, **kwargs):
|
|
237
|
+
return self.convert_markdown(self.content, ContentAST.OutputFormat.HTML)
|
|
238
|
+
|
|
239
|
+
def render_latex(self, **kwargs):
|
|
240
|
+
return self.convert_markdown(self.content, ContentAST.OutputFormat.LATEX)
|
|
241
|
+
|
|
242
|
+
def render_typst(self, **kwargs):
|
|
243
|
+
return self.convert_markdown(self.content, ContentAST.OutputFormat.TYPST) #.replace("#", r"\#")
|
|
244
|
+
|
|
245
|
+
## Top-ish Level containers
|
|
246
|
+
class Document(Container):
|
|
247
|
+
"""
|
|
248
|
+
Root document container for complete quiz documents with proper headers and structure.
|
|
249
|
+
|
|
250
|
+
This class provides document-level rendering with appropriate headers, packages,
|
|
251
|
+
and formatting for complete LaTeX documents. It's primarily used internally
|
|
252
|
+
by the quiz generation system.
|
|
253
|
+
|
|
254
|
+
When to use:
|
|
255
|
+
- Creating standalone PDF documents (handled automatically by quiz system)
|
|
256
|
+
- Need complete LaTeX document structure with packages and headers
|
|
257
|
+
- Root container for entire quiz content
|
|
258
|
+
|
|
259
|
+
Note: Most question developers should NOT use this directly.
|
|
260
|
+
Use ContentAST.Section for question bodies and explanations instead.
|
|
261
|
+
|
|
262
|
+
Features:
|
|
263
|
+
- Complete LaTeX document headers with all necessary packages
|
|
264
|
+
- Automatic title handling across all formats
|
|
265
|
+
- PDF-ready formatting with proper spacing and layout
|
|
266
|
+
|
|
267
|
+
Example (internal use):
|
|
268
|
+
# Usually created automatically by quiz system
|
|
269
|
+
doc = ContentAST.Document(title="Midterm Exam")
|
|
270
|
+
doc.add_element(question_section)
|
|
271
|
+
pdf_content = doc.render("latex")
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
LATEX_HEADER = textwrap.dedent(r"""
|
|
275
|
+
\documentclass[12pt]{article}
|
|
276
|
+
|
|
277
|
+
% Page layout
|
|
278
|
+
\usepackage[a4paper, margin=1.5cm]{geometry}
|
|
279
|
+
|
|
280
|
+
% Graphics for QR codes
|
|
281
|
+
\usepackage{graphicx} % For including QR code images
|
|
282
|
+
|
|
283
|
+
% Math packages
|
|
284
|
+
\usepackage[leqno,fleqn]{amsmath} % For advanced math environments (matrices, equations)
|
|
285
|
+
\setlength{\mathindent}{0pt} % flush left
|
|
286
|
+
\usepackage{amsfonts} % For additional math fonts
|
|
287
|
+
\usepackage{amssymb} % For additional math symbols
|
|
288
|
+
|
|
289
|
+
% Tables and formatting
|
|
290
|
+
\usepackage{booktabs} % For clean table rules
|
|
291
|
+
\usepackage{array} % For extra column formatting options
|
|
292
|
+
\usepackage{verbatim} % For verbatim environments (code blocks)
|
|
293
|
+
\usepackage{enumitem} % For customized list spacing
|
|
294
|
+
\usepackage{setspace} % For \onehalfspacing
|
|
295
|
+
|
|
296
|
+
% Setting up Code environments
|
|
297
|
+
\let\originalverbatim\verbatim
|
|
298
|
+
\let\endoriginalverbatim\endverbatim
|
|
299
|
+
\renewenvironment{verbatim}
|
|
300
|
+
{\small\setlength{\baselineskip}{0.8\baselineskip}\originalverbatim}
|
|
301
|
+
{\endoriginalverbatim}
|
|
302
|
+
|
|
303
|
+
% Listings (for code)
|
|
304
|
+
\usepackage[final]{listings}
|
|
305
|
+
\lstset{
|
|
306
|
+
basicstyle=\ttfamily,
|
|
307
|
+
columns=fullflexible,
|
|
308
|
+
frame=single,
|
|
309
|
+
breaklines=true,
|
|
310
|
+
postbreak=\mbox{$\hookrightarrow$\,} % You can remove or customize this
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
% Custom commands
|
|
314
|
+
\newcounter{NumQuestions}
|
|
315
|
+
\newcommand{\question}[1]{%
|
|
316
|
+
\vspace{0.5cm}
|
|
317
|
+
\stepcounter{NumQuestions}%
|
|
318
|
+
\noindent\textbf{Question \theNumQuestions:} \hfill \rule{0.5cm}{0.15mm} / #1
|
|
319
|
+
\par\vspace{0.1cm}
|
|
320
|
+
}
|
|
321
|
+
\newcommand{\answerblank}[1]{\rule{0pt}{10mm}\rule[-1.5mm]{#1cm}{0.15mm}}
|
|
322
|
+
|
|
323
|
+
% Optional: spacing for itemized lists
|
|
324
|
+
\setlist[itemize]{itemsep=10pt, parsep=5pt}
|
|
325
|
+
\providecommand{\tightlist}{%
|
|
326
|
+
\setlength{\itemsep}{10pt}\setlength{\parskip}{10pt}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
\begin{document}
|
|
330
|
+
""")
|
|
331
|
+
|
|
332
|
+
TYPST_HEADER = textwrap.dedent("""
|
|
333
|
+
#import "@preview/wrap-it:0.1.1": wrap-content
|
|
334
|
+
|
|
335
|
+
// Quiz document settings
|
|
336
|
+
#set page(
|
|
337
|
+
paper: "us-letter",
|
|
338
|
+
margin: 1.5cm,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
#set text(
|
|
342
|
+
size: 12pt,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
// Math equation settings
|
|
346
|
+
#set math.equation(numbering: none)
|
|
347
|
+
|
|
348
|
+
// Paragraph spacing
|
|
349
|
+
#set par(
|
|
350
|
+
spacing: 1.0em,
|
|
351
|
+
leading: 0.5em,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
// Question counter and command
|
|
355
|
+
#let question_num = counter("question")
|
|
356
|
+
|
|
357
|
+
#let question(points, content, spacing: 3cm, qr_code: none) = {
|
|
358
|
+
block(breakable: false)[
|
|
359
|
+
#line(length: 100%, stroke: 1pt)
|
|
360
|
+
#v(0cm)
|
|
361
|
+
#question_num.step()
|
|
362
|
+
|
|
363
|
+
*Question #context question_num.display():* (#points #if points == 1 [point] else [points])
|
|
364
|
+
#v(0.0cm)
|
|
365
|
+
|
|
366
|
+
/*
|
|
367
|
+
#if qr_code != none {
|
|
368
|
+
let fig = figure(image(qr_code, width: 2cm))
|
|
369
|
+
// let fig = square(fill: teal, radius: 0.5em, width: 8em) // for debugging
|
|
370
|
+
wrap-content(fig, align: top + right)[
|
|
371
|
+
#h(100%) // force the wrapper to fill line width
|
|
372
|
+
#content
|
|
373
|
+
]
|
|
374
|
+
} else {
|
|
375
|
+
content
|
|
376
|
+
}
|
|
377
|
+
*/
|
|
378
|
+
|
|
379
|
+
#grid(
|
|
380
|
+
columns: (1fr, auto),
|
|
381
|
+
gutter: 1em,
|
|
382
|
+
align: top,
|
|
383
|
+
)[
|
|
384
|
+
#content
|
|
385
|
+
#v(spacing)
|
|
386
|
+
][
|
|
387
|
+
#image(qr_code, width: 2cm)
|
|
388
|
+
]
|
|
389
|
+
#if spacing >= 199cm {
|
|
390
|
+
|
|
391
|
+
"Note: the next page is left blank for you to show work."
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
]
|
|
395
|
+
// Check if spacing >= 199cm (EXTRA_PAGE preset)
|
|
396
|
+
// If so, add both spacing and a pagebreak for a full blank page
|
|
397
|
+
if spacing >= 199cm {
|
|
398
|
+
|
|
399
|
+
pagebreak()
|
|
400
|
+
pagebreak()
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Fill-in line for inline answer blanks (tables, etc.)
|
|
405
|
+
#let fillline(width: 5cm, height: 1.2em, stroke: 0.5pt) = {
|
|
406
|
+
box(width: width, height: height, baseline: 0.25em)[
|
|
407
|
+
#align(bottom + left)[
|
|
408
|
+
#line(length: 100%, stroke: stroke)
|
|
409
|
+
]
|
|
410
|
+
]
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Code block styling
|
|
414
|
+
#show raw.where(block: true): set text(size: 8pt)
|
|
415
|
+
#show raw.where(block: true): block.with(
|
|
416
|
+
fill: luma(240),
|
|
417
|
+
inset: 10pt,
|
|
418
|
+
radius: 4pt,
|
|
419
|
+
)
|
|
420
|
+
""")
|
|
421
|
+
|
|
422
|
+
def __init__(self, title=None):
|
|
423
|
+
super().__init__()
|
|
424
|
+
self.title = title
|
|
425
|
+
|
|
426
|
+
def render(self, output_format, **kwargs):
|
|
427
|
+
# Generate content from all elements
|
|
428
|
+
content = super().render(output_format, **kwargs)
|
|
429
|
+
|
|
430
|
+
# Add title if present
|
|
431
|
+
if self.title and output_format == "markdown":
|
|
432
|
+
content = f"# {self.title}\n\n{content}"
|
|
433
|
+
elif self.title and output_format == "html":
|
|
434
|
+
content = f"<h1>{self.title}</h1>\n{content}"
|
|
435
|
+
elif self.title and output_format == "latex":
|
|
436
|
+
content = f"\\section{{{self.title}}}\n{content}"
|
|
437
|
+
|
|
438
|
+
return content
|
|
439
|
+
|
|
440
|
+
def render_latex(self, **kwargs):
|
|
441
|
+
latex = self.LATEX_HEADER
|
|
442
|
+
latex += f"\\title{{{self.title}}}\n"
|
|
443
|
+
latex += textwrap.dedent(f"""
|
|
444
|
+
\\noindent\\Large {self.title} \\hfill \\normalsize Name: \\answerblank{{{5}}}
|
|
445
|
+
|
|
446
|
+
\\vspace{{0.5cm}}
|
|
447
|
+
\\onehalfspacing
|
|
448
|
+
|
|
449
|
+
""")
|
|
450
|
+
|
|
451
|
+
latex += "\n".join(element.render(ContentAST.OutputFormat.LATEX, **kwargs) for element in self.elements)
|
|
452
|
+
|
|
453
|
+
latex += r"\end{document}"
|
|
454
|
+
|
|
455
|
+
return latex
|
|
456
|
+
|
|
457
|
+
def render_typst(self, **kwargs):
|
|
458
|
+
"""Render complete Typst document with header and title"""
|
|
459
|
+
typst = self.TYPST_HEADER
|
|
460
|
+
|
|
461
|
+
# Add title and name line using grid for proper alignment
|
|
462
|
+
typst += f"\n#grid(\n"
|
|
463
|
+
typst += f" columns: (1fr, auto),\n"
|
|
464
|
+
typst += f" align: (left, right),\n"
|
|
465
|
+
typst += f" [#text(size: 14pt, weight: \"bold\")[{self.title}]],\n"
|
|
466
|
+
typst += f" [Name: #fillline(width: 5cm)]\n"
|
|
467
|
+
typst += f")\n"
|
|
468
|
+
typst += f"#v(0.5cm)\n"
|
|
469
|
+
|
|
470
|
+
# Render all elements
|
|
471
|
+
typst += "".join(element.render(ContentAST.OutputFormat.TYPST, **kwargs) for element in self.elements)
|
|
472
|
+
|
|
473
|
+
return typst
|
|
474
|
+
|
|
475
|
+
class Question(Container):
|
|
476
|
+
"""
|
|
477
|
+
Complete question container with body, explanation, and metadata.
|
|
478
|
+
|
|
479
|
+
This class represents a full question with both the question content
|
|
480
|
+
and its explanation/solution. It handles question-level formatting
|
|
481
|
+
like point values, spacing, and PDF layout.
|
|
482
|
+
|
|
483
|
+
Note: Most question developers should NOT use this directly.
|
|
484
|
+
It's created automatically by the quiz generation system.
|
|
485
|
+
Focus on building ContentAST.Section objects for get_body() and get_explanation().
|
|
486
|
+
|
|
487
|
+
When to use:
|
|
488
|
+
- Creating complete question objects (handled by quiz system)
|
|
489
|
+
- Custom question wrappers (advanced use)
|
|
490
|
+
|
|
491
|
+
Example (internal use):
|
|
492
|
+
# Usually created by quiz system from your question classes
|
|
493
|
+
body = ContentAST.Section()
|
|
494
|
+
body.add_element(ContentAST.Paragraph(["What is 2+2?"]))
|
|
495
|
+
|
|
496
|
+
explanation = ContentAST.Section()
|
|
497
|
+
explanation.add_element(ContentAST.Paragraph(["2+2=4"]))
|
|
498
|
+
|
|
499
|
+
question = ContentAST.Question(body=body, explanation=explanation, value=5)
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
def __init__(
|
|
503
|
+
self,
|
|
504
|
+
body: ContentAST.Section,
|
|
505
|
+
explanation: ContentAST.Section,
|
|
506
|
+
name=None,
|
|
507
|
+
value=1,
|
|
508
|
+
interest=1.0,
|
|
509
|
+
spacing=0,
|
|
510
|
+
topic=None,
|
|
511
|
+
question_number=None,
|
|
512
|
+
**kwargs
|
|
513
|
+
):
|
|
514
|
+
super().__init__()
|
|
515
|
+
self.name = name
|
|
516
|
+
self.explanation = explanation
|
|
517
|
+
self.body = body
|
|
518
|
+
self.value = value
|
|
519
|
+
self.interest = interest
|
|
520
|
+
self.spacing = spacing
|
|
521
|
+
self.topic = topic # todo: remove this bs.
|
|
522
|
+
self.question_number = question_number # For QR code generation
|
|
523
|
+
|
|
524
|
+
self.default_kwargs = kwargs
|
|
525
|
+
|
|
526
|
+
def render(self, output_format, **kwargs):
|
|
527
|
+
updated_kwargs = self.default_kwargs
|
|
528
|
+
updated_kwargs.update(kwargs)
|
|
529
|
+
|
|
530
|
+
log.debug(f"updated_kwargs: {updated_kwargs}")
|
|
531
|
+
|
|
532
|
+
# Special handling for latex and typst - use dedicated render methods
|
|
533
|
+
if output_format == "typst":
|
|
534
|
+
return self.render_typst(**kwargs)
|
|
535
|
+
|
|
536
|
+
# Generate content from all elements
|
|
537
|
+
content = self.body.render(output_format, **updated_kwargs)
|
|
538
|
+
|
|
539
|
+
# If output format is latex, add in minipage and question environments
|
|
540
|
+
if output_format == "latex":
|
|
541
|
+
# Build question header - either with or without QR code
|
|
542
|
+
if self.question_number is not None:
|
|
543
|
+
try:
|
|
544
|
+
from QuizGenerator.qrcode_generator import QuestionQRCode
|
|
545
|
+
|
|
546
|
+
# Build extra_data dict with regeneration metadata if available
|
|
547
|
+
extra_data = {}
|
|
548
|
+
if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed') and hasattr(
|
|
549
|
+
self, 'question_version'
|
|
550
|
+
):
|
|
551
|
+
if self.question_class_name and self.generation_seed is not None and self.question_version:
|
|
552
|
+
extra_data['question_type'] = self.question_class_name
|
|
553
|
+
extra_data['seed'] = self.generation_seed
|
|
554
|
+
extra_data['version'] = self.question_version
|
|
555
|
+
# Include question-specific configuration parameters if available
|
|
556
|
+
if hasattr(self, 'config_params') and self.config_params:
|
|
557
|
+
extra_data['config'] = self.config_params
|
|
558
|
+
|
|
559
|
+
qr_path = QuestionQRCode.generate_qr_pdf(
|
|
560
|
+
self.question_number,
|
|
561
|
+
self.value,
|
|
562
|
+
**extra_data
|
|
563
|
+
)
|
|
564
|
+
# Build custom question header with QR code centered
|
|
565
|
+
# Format: Question N: [QR code centered] __ / points
|
|
566
|
+
question_header = (
|
|
567
|
+
r"\vspace{0.5cm}" + "\n"
|
|
568
|
+
r"\noindent\textbf{Question " + str(self.question_number) + r":} \hfill "
|
|
569
|
+
r"\rule{0.5cm}{0.15mm} / " + str(
|
|
570
|
+
int(self.value)
|
|
571
|
+
) + "\n"
|
|
572
|
+
r"\raisebox{-1cm}{" # Reduced lift to minimize extra space above
|
|
573
|
+
rf"\includegraphics[width={QuestionQRCode.DEFAULT_SIZE_CM}cm]{{{qr_path}}}"
|
|
574
|
+
r"} "
|
|
575
|
+
r"\par\vspace{-1cm}"
|
|
576
|
+
)
|
|
577
|
+
except Exception as e:
|
|
578
|
+
log.warning(f"Failed to generate QR code for question {self.question_number}: {e}")
|
|
579
|
+
# Fall back to standard question macro
|
|
580
|
+
question_header = r"\question{" + str(int(self.value)) + r"}"
|
|
581
|
+
else:
|
|
582
|
+
# Use standard question macro if no question number
|
|
583
|
+
question_header = r"\question{" + str(int(self.value)) + r"}"
|
|
584
|
+
|
|
585
|
+
latex_lines = [
|
|
586
|
+
r"\noindent\begin{minipage}{\textwidth}",
|
|
587
|
+
r"\noindent\makebox[\linewidth]{\rule{\paperwidth}{1pt}}",
|
|
588
|
+
question_header,
|
|
589
|
+
r"\noindent\begin{minipage}{0.9\textwidth}",
|
|
590
|
+
content,
|
|
591
|
+
f"\\vspace{{{self.spacing}cm}}"
|
|
592
|
+
r"\end{minipage}",
|
|
593
|
+
r"\end{minipage}",
|
|
594
|
+
"\n\n",
|
|
595
|
+
]
|
|
596
|
+
content = '\n'.join(latex_lines)
|
|
597
|
+
|
|
598
|
+
log.debug(f"content: \n{content}")
|
|
599
|
+
|
|
600
|
+
return content
|
|
601
|
+
|
|
602
|
+
def render_typst(self, **kwargs):
|
|
603
|
+
"""Render question in Typst format with proper formatting"""
|
|
604
|
+
# Render question body
|
|
605
|
+
content = self.body.render(ContentAST.OutputFormat.TYPST, **kwargs)
|
|
606
|
+
|
|
607
|
+
# Generate QR code if question number is available
|
|
608
|
+
qr_param = ""
|
|
609
|
+
if self.question_number is not None:
|
|
610
|
+
try:
|
|
611
|
+
|
|
612
|
+
# Build extra_data dict with regeneration metadata if available
|
|
613
|
+
extra_data = {}
|
|
614
|
+
if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed') and hasattr(
|
|
615
|
+
self, 'question_version'
|
|
616
|
+
):
|
|
617
|
+
if self.question_class_name and self.generation_seed is not None and self.question_version:
|
|
618
|
+
extra_data['question_type'] = self.question_class_name
|
|
619
|
+
extra_data['seed'] = self.generation_seed
|
|
620
|
+
extra_data['version'] = self.question_version
|
|
621
|
+
# Include question-specific configuration parameters if available
|
|
622
|
+
if hasattr(self, 'config_params') and self.config_params:
|
|
623
|
+
extra_data['config'] = self.config_params
|
|
624
|
+
|
|
625
|
+
# Generate QR code PNG
|
|
626
|
+
qr_path = QuestionQRCode.generate_qr_pdf(
|
|
627
|
+
self.question_number,
|
|
628
|
+
self.value,
|
|
629
|
+
scale=1,
|
|
630
|
+
**extra_data
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Add QR code parameter to question function call
|
|
634
|
+
qr_param = f'qr_code: "{qr_path}"'
|
|
635
|
+
|
|
636
|
+
except Exception as e:
|
|
637
|
+
log.warning(f"Failed to generate QR code for question {self.question_number}: {e}")
|
|
638
|
+
|
|
639
|
+
# Use the question function which handles all formatting including non-breaking
|
|
640
|
+
return textwrap.dedent(f"""
|
|
641
|
+
#question(
|
|
642
|
+
{int(self.value)},
|
|
643
|
+
spacing: {self.spacing}cm{'' if not qr_param else ", "}
|
|
644
|
+
{qr_param}
|
|
645
|
+
)[
|
|
646
|
+
""") + content + "\n]\n\n"
|
|
647
|
+
|
|
648
|
+
class Section(Container):
|
|
649
|
+
"""
|
|
650
|
+
Primary container for question content - USE THIS for get_body() and get_explanation().
|
|
651
|
+
|
|
652
|
+
This is the most important ContentAST class for question developers.
|
|
653
|
+
It serves as the main container for organizing question content
|
|
654
|
+
and should be the return type for your get_body() and get_explanation() methods.
|
|
655
|
+
|
|
656
|
+
CRITICAL: Always use ContentAST.Section as the container for:
|
|
657
|
+
- Question body content (return from get_body())
|
|
658
|
+
- Question explanation/solution content (return from get_explanation())
|
|
659
|
+
- Any grouped content that needs to render together
|
|
660
|
+
|
|
661
|
+
When to use:
|
|
662
|
+
- As the root container in get_body() and get_explanation() methods
|
|
663
|
+
- Grouping related content elements
|
|
664
|
+
- Organizing complex question content
|
|
665
|
+
|
|
666
|
+
Example:
|
|
667
|
+
def get_body(self):
|
|
668
|
+
body = ContentAST.Section()
|
|
669
|
+
body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
|
|
670
|
+
|
|
671
|
+
matrix_data = [[1, 2], [3, 4]]
|
|
672
|
+
body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
|
|
673
|
+
|
|
674
|
+
body.add_element(ContentAST.Answer(answer=self.answer, label="Determinant"))
|
|
675
|
+
return body
|
|
676
|
+
"""
|
|
677
|
+
pass
|
|
678
|
+
|
|
679
|
+
# Individual elements
|
|
680
|
+
class Text(Leaf):
|
|
681
|
+
"""
|
|
682
|
+
Basic text content with automatic format conversion and selective visibility.
|
|
683
|
+
|
|
684
|
+
This is the fundamental text element that handles plain text content
|
|
685
|
+
with automatic markdown-to-format conversion. It supports emphasis
|
|
686
|
+
and format-specific hiding.
|
|
687
|
+
|
|
688
|
+
When to use:
|
|
689
|
+
- Plain text content that needs cross-format rendering
|
|
690
|
+
- Text that should be hidden from specific output formats
|
|
691
|
+
- Simple text with optional emphasis
|
|
692
|
+
|
|
693
|
+
DON'T use for:
|
|
694
|
+
- Mathematical content (use ContentAST.Equation instead)
|
|
695
|
+
- Code (use ContentAST.Code instead)
|
|
696
|
+
- Structured content (use ContentAST.Paragraph for grouping)
|
|
697
|
+
|
|
698
|
+
Example:
|
|
699
|
+
# Basic text
|
|
700
|
+
text = ContentAST.Text("This is plain text")
|
|
701
|
+
|
|
702
|
+
# Emphasized text
|
|
703
|
+
important = ContentAST.Text("Important note", emphasis=True)
|
|
704
|
+
|
|
705
|
+
# HTML-only text (hidden from PDF)
|
|
706
|
+
web_note = ContentAST.Text("Click submit", hide_from_latex=True)
|
|
707
|
+
"""
|
|
708
|
+
def __init__(self, content : str, *, hide_from_latex=False, hide_from_html=False, emphasis=False):
|
|
709
|
+
super().__init__(content)
|
|
710
|
+
self.hide_from_latex = hide_from_latex
|
|
711
|
+
self.hide_from_html = hide_from_html
|
|
712
|
+
self.emphasis = emphasis
|
|
713
|
+
|
|
714
|
+
def render_markdown(self, **kwargs):
|
|
715
|
+
return f"{'***' if self.emphasis else ''}{self.content}{'***' if self.emphasis else ''}"
|
|
716
|
+
|
|
717
|
+
def render_html(self, **kwargs):
|
|
718
|
+
if self.hide_from_html:
|
|
719
|
+
return ""
|
|
720
|
+
return self.convert_markdown(self.content,ContentAST.OutputFormat.HTML)
|
|
721
|
+
|
|
722
|
+
def render_latex(self, **kwargs):
|
|
723
|
+
if self.hide_from_latex:
|
|
724
|
+
return ""
|
|
725
|
+
return self.convert_markdown(self.content.replace("#", r"\#"), ContentAST.OutputFormat.LATEX)
|
|
726
|
+
|
|
727
|
+
def render_typst(self, **kwargs):
|
|
728
|
+
"""Render text to Typst, escaping special characters."""
|
|
729
|
+
if self.hide_from_latex:
|
|
730
|
+
return ""
|
|
731
|
+
|
|
732
|
+
# This is for when we are passing in a code block via a FromText question
|
|
733
|
+
content = re.sub(
|
|
734
|
+
r"```\s*(.*)\s*```",
|
|
735
|
+
r"""
|
|
736
|
+
#box(
|
|
737
|
+
raw("\1",
|
|
738
|
+
block: true
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
""",
|
|
742
|
+
self.content,
|
|
743
|
+
flags=re.DOTALL
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# In Typst, # starts code/function calls, so we need to escape it
|
|
747
|
+
content = content.replace("# ", r"\# ")
|
|
748
|
+
|
|
749
|
+
if self.emphasis:
|
|
750
|
+
content = f"*{content}*"
|
|
751
|
+
return content
|
|
752
|
+
|
|
753
|
+
def is_mergeable(self, other: ContentAST.Element):
|
|
754
|
+
if not isinstance(other, ContentAST.Text):
|
|
755
|
+
return False
|
|
756
|
+
if self.hide_from_latex != other.hide_from_latex:
|
|
757
|
+
return False
|
|
758
|
+
return True
|
|
759
|
+
|
|
760
|
+
def merge(self, other: ContentAST.Text):
|
|
761
|
+
self.content = self.render_markdown() + " " + other.render_markdown()
|
|
762
|
+
self.emphasis = False
|
|
763
|
+
|
|
764
|
+
class Code(Text):
|
|
765
|
+
"""
|
|
766
|
+
Code block formatter with proper syntax highlighting and monospace formatting.
|
|
767
|
+
|
|
768
|
+
Use this for displaying source code, terminal output, file contents,
|
|
769
|
+
or any content that should appear in monospace font with preserved formatting.
|
|
770
|
+
|
|
771
|
+
When to use:
|
|
772
|
+
- Source code examples
|
|
773
|
+
- Terminal/shell output
|
|
774
|
+
- File contents or configuration
|
|
775
|
+
- Any monospace-formatted text
|
|
776
|
+
|
|
777
|
+
Features:
|
|
778
|
+
- Automatic code block formatting in markdown
|
|
779
|
+
- Proper HTML code styling
|
|
780
|
+
- LaTeX verbatim environments
|
|
781
|
+
- Preserved whitespace and line breaks
|
|
782
|
+
|
|
783
|
+
Example:
|
|
784
|
+
# Code snippet
|
|
785
|
+
code_block = ContentAST.Code(
|
|
786
|
+
"if (x > 0) {\n print('positive');\n}"
|
|
787
|
+
)
|
|
788
|
+
body.add_element(code_block)
|
|
789
|
+
|
|
790
|
+
# Terminal output
|
|
791
|
+
terminal = ContentAST.Code("$ ls -la\ntotal 24\ndrwxr-xr-x 3 user")
|
|
792
|
+
"""
|
|
793
|
+
def __init__(self, lines, **kwargs):
|
|
794
|
+
super().__init__(lines)
|
|
795
|
+
self.make_normal = kwargs.get("make_normal", False)
|
|
796
|
+
|
|
797
|
+
def render_markdown(self, **kwargs):
|
|
798
|
+
content = "```" + self.content.rstrip() + "\n```"
|
|
799
|
+
return content
|
|
800
|
+
|
|
801
|
+
def render_html(self, **kwargs):
|
|
802
|
+
return self.convert_markdown(textwrap.indent(self.content, "\t"), ContentAST.OutputFormat.HTML)
|
|
803
|
+
|
|
804
|
+
def render_latex(self, **kwargs):
|
|
805
|
+
return self.convert_markdown(self.render_markdown(), ContentAST.OutputFormat.LATEX)
|
|
806
|
+
|
|
807
|
+
def render_typst(self, **kwargs):
|
|
808
|
+
"""Render code block in Typst with smaller monospace font."""
|
|
809
|
+
# Use raw block with 11pt font size
|
|
810
|
+
# Escape backticks in the content
|
|
811
|
+
escaped_content = self.content.replace("`", r"\`")
|
|
812
|
+
|
|
813
|
+
# Try to reduce individual pathway to ensure consistency
|
|
814
|
+
return ContentAST.Text(f"```\n{escaped_content.rstrip()}\n```").render_typst()
|
|
815
|
+
|
|
816
|
+
class Equation(Leaf):
|
|
817
|
+
"""
|
|
818
|
+
Mathematical equation renderer with LaTeX input and cross-format output.
|
|
819
|
+
|
|
820
|
+
CRITICAL: Use this for ALL mathematical content instead of manual LaTeX strings.
|
|
821
|
+
Provides consistent math rendering across PDF (LaTeX) and Canvas (MathJax).
|
|
822
|
+
|
|
823
|
+
When to use:
|
|
824
|
+
- Any mathematical expressions, equations, or formulas
|
|
825
|
+
- Variables, functions, mathematical notation
|
|
826
|
+
- Both inline math (within text) and display math (separate lines)
|
|
827
|
+
|
|
828
|
+
DON'T manually write LaTeX in ContentAST.Text - always use ContentAST.Equation.
|
|
829
|
+
|
|
830
|
+
Example:
|
|
831
|
+
# Display equation (separate line, larger)
|
|
832
|
+
body.add_element(ContentAST.Equation("x^2 + y^2 = r^2"))
|
|
833
|
+
|
|
834
|
+
# Inline equation (within text)
|
|
835
|
+
paragraph = ContentAST.Paragraph([
|
|
836
|
+
"The solution is ",
|
|
837
|
+
ContentAST.Equation("x = \\frac{-b}{2a}", inline=True),
|
|
838
|
+
" which can be computed easily."
|
|
839
|
+
])
|
|
840
|
+
|
|
841
|
+
# Complex equations
|
|
842
|
+
body.add_element(ContentAST.Equation(r"\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}"))
|
|
843
|
+
"""
|
|
844
|
+
def __init__(self, latex, inline=False):
|
|
845
|
+
super().__init__("[equation]")
|
|
846
|
+
self.latex = latex
|
|
847
|
+
self.inline = inline
|
|
848
|
+
|
|
849
|
+
def render_markdown(self, **kwargs):
|
|
850
|
+
if self.inline:
|
|
851
|
+
return f"${self.latex}$"
|
|
852
|
+
else:
|
|
853
|
+
return r"$\displaystyle " + f"{self.latex}" + r"$"
|
|
854
|
+
|
|
855
|
+
def render_html(self, **kwargs):
|
|
856
|
+
if self.inline:
|
|
857
|
+
return fr"\({self.latex}\)"
|
|
858
|
+
else:
|
|
859
|
+
return f"<div class='math'>$$ \\displaystyle {self.latex} \\; $$</div>"
|
|
860
|
+
|
|
861
|
+
def render_latex(self, **kwargs):
|
|
862
|
+
if self.inline:
|
|
863
|
+
return f"${self.latex}$~"
|
|
864
|
+
else:
|
|
865
|
+
return f"\\begin{{flushleft}}${self.latex}$\\end{{flushleft}}"
|
|
866
|
+
|
|
867
|
+
def render_typst(self, **kwargs):
|
|
868
|
+
"""
|
|
869
|
+
Render equation in Typst format.
|
|
870
|
+
|
|
871
|
+
Typst uses LaTeX-like math syntax with $ delimiters, but with different
|
|
872
|
+
symbol names. This method converts LaTeX math to Typst-compatible syntax.
|
|
873
|
+
Inline: $equation$
|
|
874
|
+
Display: $ equation $
|
|
875
|
+
"""
|
|
876
|
+
# Convert LaTeX to Typst-compatible math
|
|
877
|
+
typst_math = self._latex_to_typst(self.latex)
|
|
878
|
+
|
|
879
|
+
if self.inline:
|
|
880
|
+
# Inline math in Typst
|
|
881
|
+
return f"${typst_math}$"
|
|
882
|
+
else:
|
|
883
|
+
# Display math in Typst
|
|
884
|
+
return f"$ {typst_math} $"
|
|
885
|
+
|
|
886
|
+
@staticmethod
|
|
887
|
+
def _latex_to_typst(latex_str: str) -> str:
|
|
888
|
+
r"""
|
|
889
|
+
Convert LaTeX math syntax to Typst math syntax.
|
|
890
|
+
|
|
891
|
+
Typst uses different conventions:
|
|
892
|
+
- Greek letters: 'alpha' not '\alpha'
|
|
893
|
+
- No \left/\right: auto-sizing parentheses
|
|
894
|
+
- Operators: 'nabla' not '\nabla', 'times' not '\times'
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
# Remove \left and \right (Typst uses auto-sizing)
|
|
898
|
+
latex_str = latex_str.replace(r'\left', '').replace(r'\right', '')
|
|
899
|
+
|
|
900
|
+
# Hat Notation
|
|
901
|
+
latex_str = re.sub(r'\\hat{([^}]+)}', r'hat("\1")', latex_str) # \hat{...} -> hat(...)
|
|
902
|
+
|
|
903
|
+
# Convert subscripts and superscripts from LaTeX to Typst
|
|
904
|
+
# LaTeX uses braces: b_{out}, x_{10}, x^{2}
|
|
905
|
+
# Typst uses parentheses for multi-char: b_(out), x_(10), x^(2)
|
|
906
|
+
latex_str = re.sub(r'_{([^}]+)}', r'_("\1")', latex_str) # _{...} -> _(...)
|
|
907
|
+
latex_str = re.sub(r'\^{([^}]+)}', r'^("\1")', latex_str) # ^{...} -> ^(...)
|
|
908
|
+
|
|
909
|
+
# Convert LaTeX Greek letters to Typst syntax (remove backslash)
|
|
910
|
+
greek_letters = [
|
|
911
|
+
'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta',
|
|
912
|
+
'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'pi', 'rho', 'sigma',
|
|
913
|
+
'tau', 'phi', 'chi', 'psi', 'omega',
|
|
914
|
+
'Gamma', 'Delta', 'Theta', 'Lambda', 'Xi', 'Pi', 'Sigma', 'Phi', 'Psi', 'Omega'
|
|
915
|
+
]
|
|
916
|
+
|
|
917
|
+
for letter in greek_letters:
|
|
918
|
+
# Use word boundaries to avoid replacing parts of other commands
|
|
919
|
+
latex_str = re.sub(rf'\\{letter}\b', letter, latex_str)
|
|
920
|
+
|
|
921
|
+
# Convert LaTeX operators to Typst syntax
|
|
922
|
+
latex_str = latex_str.replace(r'\nabla', 'nabla')
|
|
923
|
+
latex_str = latex_str.replace(r'\times', 'times')
|
|
924
|
+
latex_str = latex_str.replace(r'\cdot', 'dot')
|
|
925
|
+
latex_str = latex_str.replace(r'\partial', 'diff')
|
|
926
|
+
|
|
927
|
+
# Handle matrix environments
|
|
928
|
+
if r'\begin{matrix}' in latex_str:
|
|
929
|
+
matrix_pattern = r'\[\\begin\{matrix\}(.*?)\\end\{matrix\}\]'
|
|
930
|
+
|
|
931
|
+
def replace_matrix(match):
|
|
932
|
+
content = match.group(1)
|
|
933
|
+
elements = content.split(r'\\')
|
|
934
|
+
elements = [e.strip() for e in elements if e.strip()]
|
|
935
|
+
return f"vec({', '.join(elements)})"
|
|
936
|
+
|
|
937
|
+
latex_str = re.sub(matrix_pattern, replace_matrix, latex_str)
|
|
938
|
+
|
|
939
|
+
return latex_str
|
|
940
|
+
|
|
941
|
+
@classmethod
|
|
942
|
+
def make_block_equation__multiline_equals(cls, lhs : str, rhs : List[str]):
|
|
943
|
+
equation_lines = []
|
|
944
|
+
equation_lines.extend([
|
|
945
|
+
r"\begin{array}{l}",
|
|
946
|
+
f"{lhs} = {rhs[0]} \\\\",
|
|
947
|
+
])
|
|
948
|
+
equation_lines.extend([
|
|
949
|
+
f"\\phantom{{{lhs}}} = {eq} \\\\"
|
|
950
|
+
for eq in rhs[1:]
|
|
951
|
+
])
|
|
952
|
+
equation_lines.extend([
|
|
953
|
+
r"\end{array}",
|
|
954
|
+
])
|
|
955
|
+
|
|
956
|
+
return cls('\n'.join(equation_lines))
|
|
957
|
+
|
|
958
|
+
class Matrix(Leaf):
|
|
959
|
+
"""
|
|
960
|
+
Mathematical matrix renderer for consistent cross-format display.
|
|
961
|
+
|
|
962
|
+
CRITICAL: Use this for ALL matrix and vector notation instead of manual LaTeX.
|
|
963
|
+
|
|
964
|
+
DON'T do this:
|
|
965
|
+
# Manual LaTeX (error-prone, inconsistent)
|
|
966
|
+
latex_str = f"\\\\begin{{bmatrix}} {a} & {b} \\\\\\\\ {c} & {d} \\\\end{{bmatrix}}"
|
|
967
|
+
|
|
968
|
+
DO this instead:
|
|
969
|
+
# ContentAST.Matrix (consistent, cross-format)
|
|
970
|
+
matrix_data = [[a, b], [c, d]]
|
|
971
|
+
ContentAST.Matrix(data=matrix_data, bracket_type="b")
|
|
972
|
+
|
|
973
|
+
For vectors (single column matrices):
|
|
974
|
+
vector_data = [[v1], [v2], [v3]] # Note: list of single-element lists
|
|
975
|
+
ContentAST.Matrix(data=vector_data, bracket_type="b")
|
|
976
|
+
|
|
977
|
+
For LaTeX strings in equations:
|
|
978
|
+
matrix_latex = ContentAST.Matrix.to_latex(matrix_data, "b")
|
|
979
|
+
ContentAST.Equation(f"A = {matrix_latex}")
|
|
980
|
+
|
|
981
|
+
Bracket types:
|
|
982
|
+
- "b": square brackets [matrix] - most common for vectors/matrices
|
|
983
|
+
- "p": parentheses (matrix) - sometimes used for matrices
|
|
984
|
+
- "v": vertical bars |matrix| - for determinants
|
|
985
|
+
- "B": curly braces {matrix}
|
|
986
|
+
- "V": double vertical bars ||matrix|| - for norms
|
|
987
|
+
"""
|
|
988
|
+
def __init__(self, data, *, bracket_type="p", inline=False, name=None):
|
|
989
|
+
"""
|
|
990
|
+
Creates a matrix element that renders consistently across output formats.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
data: Matrix data as List[List[numbers/strings]] or numpy ndarray (1D or 2D)
|
|
994
|
+
For vectors: [[v1], [v2], [v3]] (column vector) or np.array([v1, v2, v3])
|
|
995
|
+
For matrices: [[a, b], [c, d]] or np.array([[a, b], [c, d]])
|
|
996
|
+
bracket_type: Bracket style - "b" for [], "p" for (), "v" for |, etc.
|
|
997
|
+
inline: Whether to use inline (smaller) matrix formatting
|
|
998
|
+
"""
|
|
999
|
+
super().__init__("[matrix]")
|
|
1000
|
+
|
|
1001
|
+
# Convert numpy ndarray to list format if needed
|
|
1002
|
+
import numpy as np
|
|
1003
|
+
if isinstance(data, np.ndarray):
|
|
1004
|
+
if data.ndim == 1:
|
|
1005
|
+
# 1D array: convert to column vector [[v1], [v2], [v3]]
|
|
1006
|
+
self.data = [[val] for val in data]
|
|
1007
|
+
elif data.ndim == 2:
|
|
1008
|
+
# 2D array: convert to list of lists
|
|
1009
|
+
self.data = data.tolist()
|
|
1010
|
+
else:
|
|
1011
|
+
raise ValueError(f"Matrix only supports 1D or 2D arrays, got {data.ndim}D")
|
|
1012
|
+
else:
|
|
1013
|
+
self.data = data
|
|
1014
|
+
|
|
1015
|
+
self.bracket_type = bracket_type
|
|
1016
|
+
self.inline = inline
|
|
1017
|
+
self.name = name
|
|
1018
|
+
|
|
1019
|
+
@staticmethod
|
|
1020
|
+
def to_latex(data, bracket_type="p"):
|
|
1021
|
+
"""
|
|
1022
|
+
Convert matrix data to LaTeX string for use in equations.
|
|
1023
|
+
|
|
1024
|
+
Use this when you need a LaTeX string to embed in ContentAST.Equation:
|
|
1025
|
+
matrix_latex = ContentAST.Matrix.to_latex([[1, 2], [3, 4]], "b")
|
|
1026
|
+
ContentAST.Equation(f"A = {matrix_latex}")
|
|
1027
|
+
|
|
1028
|
+
Args:
|
|
1029
|
+
data: Matrix data as List[List[numbers/strings]]
|
|
1030
|
+
bracket_type: Bracket style ("b", "p", "v", etc.)
|
|
1031
|
+
|
|
1032
|
+
Returns:
|
|
1033
|
+
str: LaTeX matrix string (e.g., "\\begin{bmatrix} 1 & 2 \\\\ 3 & 4 \\end{bmatrix}")
|
|
1034
|
+
"""
|
|
1035
|
+
rows = []
|
|
1036
|
+
for row in data:
|
|
1037
|
+
rows.append(" & ".join(str(cell) for cell in row))
|
|
1038
|
+
matrix_content = r" \\ ".join(rows)
|
|
1039
|
+
return f"\\begin{{{bracket_type}matrix}} {matrix_content} \\end{{{bracket_type}matrix}}"
|
|
1040
|
+
|
|
1041
|
+
def render_markdown(self, **kwargs):
|
|
1042
|
+
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
1043
|
+
rows = []
|
|
1044
|
+
for row in self.data:
|
|
1045
|
+
rows.append(" & ".join(str(cell) for cell in row))
|
|
1046
|
+
matrix_content = r" \\ ".join(rows)
|
|
1047
|
+
|
|
1048
|
+
if self.inline and self.bracket_type == "p":
|
|
1049
|
+
return f"$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$"
|
|
1050
|
+
else:
|
|
1051
|
+
return f"$\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}$"
|
|
1052
|
+
|
|
1053
|
+
def render_html(self, **kwargs):
|
|
1054
|
+
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
1055
|
+
rows = []
|
|
1056
|
+
if isinstance(self.data, numpy.ndarray):
|
|
1057
|
+
data = self.data.tolist()
|
|
1058
|
+
else:
|
|
1059
|
+
data = self.data
|
|
1060
|
+
for row in data:
|
|
1061
|
+
rows.append(" & ".join(str(cell) for cell in row))
|
|
1062
|
+
matrix_content = r" \\ ".join(rows)
|
|
1063
|
+
|
|
1064
|
+
if self.inline:
|
|
1065
|
+
return f"<span class='math'>$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$</span>"
|
|
1066
|
+
else:
|
|
1067
|
+
name_str = f"\\text{{{self.name}}} = " if self.name else ""
|
|
1068
|
+
return f"<div class='math'>$${name_str}\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}$$</div>"
|
|
1069
|
+
|
|
1070
|
+
def render_latex(self, **kwargs):
|
|
1071
|
+
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
1072
|
+
rows = []
|
|
1073
|
+
for row in self.data:
|
|
1074
|
+
rows.append(" & ".join(str(cell) for cell in row))
|
|
1075
|
+
matrix_content = r" \\ ".join(rows)
|
|
1076
|
+
|
|
1077
|
+
if self.inline and self.bracket_type == "p":
|
|
1078
|
+
return f"$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$"
|
|
1079
|
+
else:
|
|
1080
|
+
return f"\\[\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\]"
|
|
1081
|
+
|
|
1082
|
+
def render_typst(self, **kwargs):
|
|
1083
|
+
"""Render matrix in Typst format using mat() and vec() functions."""
|
|
1084
|
+
# Build matrix content with semicolons separating rows
|
|
1085
|
+
rows = []
|
|
1086
|
+
for row in self.data:
|
|
1087
|
+
rows.append(", ".join(str(cell) for cell in row))
|
|
1088
|
+
|
|
1089
|
+
# Check if it's a vector (single column)
|
|
1090
|
+
is_vector = all(len(row) == 1 for row in self.data)
|
|
1091
|
+
|
|
1092
|
+
if is_vector:
|
|
1093
|
+
# Use vec() for vectors
|
|
1094
|
+
matrix_content = ", ".join(str(row[0]) for row in self.data)
|
|
1095
|
+
result = f"vec({matrix_content})"
|
|
1096
|
+
else:
|
|
1097
|
+
# Use mat() for matrices with semicolons separating rows
|
|
1098
|
+
matrix_content = "; ".join(rows)
|
|
1099
|
+
result = f"mat({matrix_content})"
|
|
1100
|
+
|
|
1101
|
+
# Add bracket delimiters if needed
|
|
1102
|
+
if self.bracket_type == "b": # square brackets
|
|
1103
|
+
result = f"mat(delim: \"[\", {matrix_content})" if not is_vector else f"vec(delim: \"[\", {matrix_content})"
|
|
1104
|
+
elif self.bracket_type == "v": # vertical bars (determinant)
|
|
1105
|
+
result = f"mat(delim: \"|\", {matrix_content})"
|
|
1106
|
+
elif self.bracket_type == "B": # curly braces
|
|
1107
|
+
result = f"mat(delim: \"{{\", {matrix_content})"
|
|
1108
|
+
# "p" (parentheses) is the default, no need to specify
|
|
1109
|
+
|
|
1110
|
+
# Wrap in math mode
|
|
1111
|
+
if self.inline:
|
|
1112
|
+
return f"${result}$"
|
|
1113
|
+
else:
|
|
1114
|
+
return f"$ {result} $"
|
|
1115
|
+
|
|
1116
|
+
class Picture(Leaf):
|
|
1117
|
+
"""
|
|
1118
|
+
Image/diagram container with proper sizing and captioning.
|
|
1119
|
+
|
|
1120
|
+
Handles image content with automatic upload management for Canvas
|
|
1121
|
+
and proper LaTeX figure environments for PDF output.
|
|
1122
|
+
|
|
1123
|
+
When to use:
|
|
1124
|
+
- Diagrams, charts, or visual content
|
|
1125
|
+
- Memory layout diagrams
|
|
1126
|
+
- Process flowcharts
|
|
1127
|
+
- Any visual aid for questions
|
|
1128
|
+
|
|
1129
|
+
Features:
|
|
1130
|
+
- Automatic Canvas image upload handling
|
|
1131
|
+
- Proper LaTeX figure environments
|
|
1132
|
+
- Responsive sizing with width control
|
|
1133
|
+
- Optional captions
|
|
1134
|
+
|
|
1135
|
+
Example:
|
|
1136
|
+
# Image with caption
|
|
1137
|
+
with open('diagram.png', 'rb') as f:
|
|
1138
|
+
img_data = BytesIO(f.read())
|
|
1139
|
+
|
|
1140
|
+
picture = ContentAST.Picture(
|
|
1141
|
+
img_data=img_data,
|
|
1142
|
+
caption="Memory layout diagram",
|
|
1143
|
+
width="80%"
|
|
1144
|
+
)
|
|
1145
|
+
body.add_element(picture)
|
|
1146
|
+
"""
|
|
1147
|
+
def __init__(self, img_data, caption=None, width=None):
|
|
1148
|
+
super().__init__("[picture]")
|
|
1149
|
+
self.img_data = img_data
|
|
1150
|
+
self.caption = caption
|
|
1151
|
+
self.width = width
|
|
1152
|
+
self.path = None # Will be set when image is saved
|
|
1153
|
+
|
|
1154
|
+
def _ensure_image_saved(self):
|
|
1155
|
+
"""Save image data to file if not already saved."""
|
|
1156
|
+
if self.path is None:
|
|
1157
|
+
import os
|
|
1158
|
+
import uuid
|
|
1159
|
+
|
|
1160
|
+
# Create imgs directory if it doesn't exist (use absolute path)
|
|
1161
|
+
img_dir = os.path.abspath("imgs")
|
|
1162
|
+
if not os.path.exists(img_dir):
|
|
1163
|
+
os.makedirs(img_dir)
|
|
1164
|
+
|
|
1165
|
+
# Generate unique filename
|
|
1166
|
+
filename = f"image-{uuid.uuid4()}.png"
|
|
1167
|
+
self.path = os.path.join(img_dir, filename)
|
|
1168
|
+
|
|
1169
|
+
# Save BytesIO data to file
|
|
1170
|
+
with open(self.path, 'wb') as f:
|
|
1171
|
+
self.img_data.seek(0) # Reset buffer position
|
|
1172
|
+
f.write(self.img_data.read())
|
|
1173
|
+
|
|
1174
|
+
def render_markdown(self, **kwargs):
|
|
1175
|
+
self._ensure_image_saved()
|
|
1176
|
+
if self.caption:
|
|
1177
|
+
return f""
|
|
1178
|
+
return f""
|
|
1179
|
+
|
|
1180
|
+
def render_html(
|
|
1181
|
+
self,
|
|
1182
|
+
upload_func: Callable[[BytesIO], str] = lambda _: "",
|
|
1183
|
+
**kwargs
|
|
1184
|
+
) -> str:
|
|
1185
|
+
attrs = []
|
|
1186
|
+
if self.width:
|
|
1187
|
+
attrs.append(f'width="{self.width}"')
|
|
1188
|
+
|
|
1189
|
+
img = f'<img src="{upload_func(self.img_data)}" {" ".join(attrs)} alt="{self.caption or ""}">'
|
|
1190
|
+
|
|
1191
|
+
if self.caption:
|
|
1192
|
+
return f'<figure>\n {img}\n <figcaption>{self.caption}</figcaption>\n</figure>'
|
|
1193
|
+
return img
|
|
1194
|
+
|
|
1195
|
+
def render_latex(self, **kwargs):
|
|
1196
|
+
self._ensure_image_saved()
|
|
1197
|
+
|
|
1198
|
+
options = []
|
|
1199
|
+
if self.width:
|
|
1200
|
+
options.append(f"width={self.width}")
|
|
1201
|
+
|
|
1202
|
+
result = ["\\begin{figure}[h]"]
|
|
1203
|
+
result.append(f"\\centering")
|
|
1204
|
+
result.append(f"\\includegraphics[{','.join(options)}]{{{self.path}}}")
|
|
1205
|
+
|
|
1206
|
+
if self.caption:
|
|
1207
|
+
result.append(f"\\caption{{{self.caption}}}")
|
|
1208
|
+
|
|
1209
|
+
result.append("\\end{figure}")
|
|
1210
|
+
return "\n".join(result)
|
|
1211
|
+
|
|
1212
|
+
def render_typst(self, **kwargs):
|
|
1213
|
+
self._ensure_image_saved()
|
|
1214
|
+
|
|
1215
|
+
# Build the image function call
|
|
1216
|
+
img_params = []
|
|
1217
|
+
if self.width:
|
|
1218
|
+
img_params.append(f'width: {self.width}')
|
|
1219
|
+
|
|
1220
|
+
params_str = ', '.join(img_params) if img_params else ''
|
|
1221
|
+
|
|
1222
|
+
# Use Typst's figure and image functions
|
|
1223
|
+
result = []
|
|
1224
|
+
result.append("#figure(")
|
|
1225
|
+
result.append(f' image("{self.path}"{", " + params_str if params_str else ""}),')
|
|
1226
|
+
|
|
1227
|
+
if self.caption:
|
|
1228
|
+
result.append(f' caption: [{self.caption}]')
|
|
1229
|
+
|
|
1230
|
+
result.append(")")
|
|
1231
|
+
|
|
1232
|
+
return "\n".join(result)
|
|
1233
|
+
|
|
1234
|
+
class Answer(Leaf):
|
|
1235
|
+
"""
|
|
1236
|
+
Answer input field that renders as blanks in PDF and shows answers in HTML.
|
|
1237
|
+
|
|
1238
|
+
CRITICAL: Use this for ALL answer inputs in questions.
|
|
1239
|
+
Creates appropriate input fields that work across both PDF and Canvas formats.
|
|
1240
|
+
In PDF, renders as blank lines for students to fill in.
|
|
1241
|
+
In HTML/Canvas, can display the answer for checking.
|
|
1242
|
+
|
|
1243
|
+
When to use:
|
|
1244
|
+
- Any place where students need to input an answer
|
|
1245
|
+
- Numerical answers, short text answers, etc.
|
|
1246
|
+
- Questions requiring fill-in-the-blank responses
|
|
1247
|
+
|
|
1248
|
+
Example:
|
|
1249
|
+
# Basic answer field
|
|
1250
|
+
body.add_element(ContentAST.Answer(
|
|
1251
|
+
answer=self.answer,
|
|
1252
|
+
label="Result",
|
|
1253
|
+
unit="MB"
|
|
1254
|
+
))
|
|
1255
|
+
|
|
1256
|
+
# Multiple choice or complex answers
|
|
1257
|
+
body.add_element(ContentAST.Answer(
|
|
1258
|
+
answer=[self.answer_a, self.answer_b],
|
|
1259
|
+
label="Choose the best answer"
|
|
1260
|
+
))
|
|
1261
|
+
"""
|
|
1262
|
+
|
|
1263
|
+
def __init__(self, answer, label: str = "", unit: str = "", blank_length=5):
|
|
1264
|
+
super().__init__(label)
|
|
1265
|
+
self.answer = answer
|
|
1266
|
+
self.label = label
|
|
1267
|
+
self.unit = unit
|
|
1268
|
+
self.length = blank_length
|
|
1269
|
+
|
|
1270
|
+
def render_markdown(self, **kwargs):
|
|
1271
|
+
if not isinstance(self.answer, list):
|
|
1272
|
+
key_to_display = self.answer.key
|
|
1273
|
+
else:
|
|
1274
|
+
key_to_display = self.answer[0].key
|
|
1275
|
+
return f"{self.label + (':' if len(self.label) > 0 else '')} [{key_to_display}] {self.unit}".strip()
|
|
1276
|
+
|
|
1277
|
+
def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
|
|
1278
|
+
if can_be_numerical:
|
|
1279
|
+
return f"Calculate {self.label}"
|
|
1280
|
+
if show_answers and self.answer:
|
|
1281
|
+
# Show actual answer value using formatted display string
|
|
1282
|
+
if not isinstance(self.answer, list):
|
|
1283
|
+
answer_display = self.answer.get_display_string()
|
|
1284
|
+
else:
|
|
1285
|
+
answer_display = ", ".join(a.get_display_string() for a in self.answer)
|
|
1286
|
+
|
|
1287
|
+
label_part = f"{self.label}:" if self.label else ""
|
|
1288
|
+
unit_part = f" {self.unit}" if self.unit else ""
|
|
1289
|
+
return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
|
|
1290
|
+
else:
|
|
1291
|
+
# Default behavior: show [key]
|
|
1292
|
+
return self.render_markdown(**kwargs)
|
|
1293
|
+
|
|
1294
|
+
def render_latex(self, **kwargs):
|
|
1295
|
+
return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.length}}} {self.unit}".strip()
|
|
1296
|
+
|
|
1297
|
+
def render_typst(self, **kwargs):
|
|
1298
|
+
"""Render answer blank as an underlined space in Typst."""
|
|
1299
|
+
# Use the fillline function defined in TYPST_HEADER
|
|
1300
|
+
# Width is based on self.length (in cm)
|
|
1301
|
+
blank_width = self.length * 0.75 # Convert character length to cm
|
|
1302
|
+
blank = f"#fillline(width: {blank_width}cm)"
|
|
1303
|
+
|
|
1304
|
+
label_part = f"{self.label}:" if self.label else ""
|
|
1305
|
+
unit_part = f" {self.unit}" if self.unit else ""
|
|
1306
|
+
|
|
1307
|
+
return f"{label_part} {blank}{unit_part}".strip()
|
|
1308
|
+
|
|
1309
|
+
class LineBreak(Text):
|
|
1310
|
+
def __init__(self, *args, **kwargs):
|
|
1311
|
+
super().__init__("\n\n")
|
|
1312
|
+
|
|
1313
|
+
## Containers
|
|
1314
|
+
|
|
1315
|
+
class Paragraph(Container):
|
|
1316
|
+
"""
|
|
1317
|
+
Text block container with proper spacing and paragraph formatting.
|
|
1318
|
+
|
|
1319
|
+
IMPORTANT: Use this for grouping text content, especially in question bodies.
|
|
1320
|
+
Automatically handles spacing between paragraphs and combines multiple
|
|
1321
|
+
lines/elements into a cohesive text block.
|
|
1322
|
+
|
|
1323
|
+
When to use:
|
|
1324
|
+
- Question instructions or problem statements
|
|
1325
|
+
- Multi-line text content
|
|
1326
|
+
- Grouping related text elements
|
|
1327
|
+
- Any text that should be visually separated as a paragraph
|
|
1328
|
+
|
|
1329
|
+
When NOT to use:
|
|
1330
|
+
- Single words or short phrases (use ContentAST.Text)
|
|
1331
|
+
- Mathematical content (use ContentAST.Equation)
|
|
1332
|
+
- Structured data (use ContentAST.Table)
|
|
1333
|
+
|
|
1334
|
+
Example:
|
|
1335
|
+
# Multi-line question text
|
|
1336
|
+
body.add_element(ContentAST.Paragraph([
|
|
1337
|
+
"Consider the following system:",
|
|
1338
|
+
"- Process A requires 4MB memory",
|
|
1339
|
+
"- Process B requires 2MB memory",
|
|
1340
|
+
"How much total memory is needed?"
|
|
1341
|
+
]))
|
|
1342
|
+
|
|
1343
|
+
# Mixed content paragraph
|
|
1344
|
+
para = ContentAST.Paragraph([
|
|
1345
|
+
"The equation ",
|
|
1346
|
+
ContentAST.Equation("x^2 + 1 = 0", inline=True),
|
|
1347
|
+
" has no real solutions."
|
|
1348
|
+
])
|
|
1349
|
+
"""
|
|
1350
|
+
|
|
1351
|
+
def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
|
|
1352
|
+
super().__init__(add_spacing_before=True)
|
|
1353
|
+
for line in lines_or_elements:
|
|
1354
|
+
if isinstance(line, str):
|
|
1355
|
+
self.elements.append(ContentAST.Text(line))
|
|
1356
|
+
else:
|
|
1357
|
+
self.elements.append(line)
|
|
1358
|
+
|
|
1359
|
+
def render(self, output_format, **kwargs):
|
|
1360
|
+
# Add in new lines to break these up visually
|
|
1361
|
+
return "\n\n" + super().render(output_format, **kwargs) + "\n\n"
|
|
1362
|
+
|
|
1363
|
+
def render_html(self, **kwargs):
|
|
1364
|
+
return super().render_html(**kwargs) + "<br>"
|
|
1365
|
+
|
|
1366
|
+
def add_line(self, line: str):
|
|
1367
|
+
self.elements.append(ContentAST.Text(line))
|
|
1368
|
+
|
|
1369
|
+
class Table(Container):
|
|
1370
|
+
"""
|
|
1371
|
+
Structured data table with cross-format rendering and proper formatting.
|
|
1372
|
+
|
|
1373
|
+
Creates properly formatted tables that work in PDF, Canvas, and Markdown.
|
|
1374
|
+
Automatically handles headers, alignment, and responsive formatting.
|
|
1375
|
+
All data is converted to ContentAST elements for consistent rendering.
|
|
1376
|
+
|
|
1377
|
+
When to use:
|
|
1378
|
+
- Structured data presentation (comparison tables, data sets)
|
|
1379
|
+
- Answer choices in tabular format
|
|
1380
|
+
- Organized information display
|
|
1381
|
+
- Memory layout diagrams, process tables, etc.
|
|
1382
|
+
|
|
1383
|
+
Features:
|
|
1384
|
+
- Automatic alignment control (left, right, center)
|
|
1385
|
+
- Optional headers with proper formatting
|
|
1386
|
+
- Canvas-compatible HTML output
|
|
1387
|
+
- LaTeX booktabs for professional PDF tables
|
|
1388
|
+
|
|
1389
|
+
Example:
|
|
1390
|
+
# Basic data table
|
|
1391
|
+
data = [
|
|
1392
|
+
["Process A", "4MB", "Running"],
|
|
1393
|
+
["Process B", "2MB", "Waiting"]
|
|
1394
|
+
]
|
|
1395
|
+
headers = ["Process", "Memory", "Status"]
|
|
1396
|
+
table = ContentAST.Table(data=data, headers=headers, alignments=["left", "right", "center"])
|
|
1397
|
+
body.add_element(table)
|
|
1398
|
+
|
|
1399
|
+
# Mixed content table
|
|
1400
|
+
data = [
|
|
1401
|
+
[ContentAST.Text("x"), ContentAST.Equation("x^2", inline=True)],
|
|
1402
|
+
[ContentAST.Text("y"), ContentAST.Equation("y^2", inline=True)]
|
|
1403
|
+
]
|
|
1404
|
+
"""
|
|
1405
|
+
|
|
1406
|
+
def __init__(self, data, headers=None, alignments=None, padding=False, transpose=False, hide_rules=False):
|
|
1407
|
+
# todo: fix alignments
|
|
1408
|
+
# todo: implement transpose
|
|
1409
|
+
super().__init__()
|
|
1410
|
+
|
|
1411
|
+
# Normalize data to ContentAST elements
|
|
1412
|
+
self.data = []
|
|
1413
|
+
for row in data:
|
|
1414
|
+
normalized_row = []
|
|
1415
|
+
for cell in row:
|
|
1416
|
+
if isinstance(cell, ContentAST.Element):
|
|
1417
|
+
normalized_row.append(cell)
|
|
1418
|
+
else:
|
|
1419
|
+
normalized_row.append(ContentAST.Text(str(cell)))
|
|
1420
|
+
self.data.append(normalized_row)
|
|
1421
|
+
|
|
1422
|
+
# Normalize headers to ContentAST elements
|
|
1423
|
+
if headers:
|
|
1424
|
+
self.headers = []
|
|
1425
|
+
for header in headers:
|
|
1426
|
+
if isinstance(header, ContentAST.Element):
|
|
1427
|
+
self.headers.append(header)
|
|
1428
|
+
else:
|
|
1429
|
+
self.headers.append(ContentAST.Text(str(header)))
|
|
1430
|
+
else:
|
|
1431
|
+
self.headers = None
|
|
1432
|
+
|
|
1433
|
+
self.alignments = alignments
|
|
1434
|
+
self.padding = padding,
|
|
1435
|
+
self.hide_rules = hide_rules
|
|
1436
|
+
|
|
1437
|
+
def render_markdown(self, **kwargs):
|
|
1438
|
+
# Basic markdown table implementation
|
|
1439
|
+
result = []
|
|
1440
|
+
|
|
1441
|
+
if self.headers:
|
|
1442
|
+
result.append("| " + " | ".join(str(h) for h in self.headers) + " |")
|
|
1443
|
+
|
|
1444
|
+
if self.alignments:
|
|
1445
|
+
align_row = []
|
|
1446
|
+
for align in self.alignments:
|
|
1447
|
+
if align == "left":
|
|
1448
|
+
align_row.append(":---")
|
|
1449
|
+
elif align == "right":
|
|
1450
|
+
align_row.append("---:")
|
|
1451
|
+
else: # center
|
|
1452
|
+
align_row.append(":---:")
|
|
1453
|
+
result.append("| " + " | ".join(align_row) + " |")
|
|
1454
|
+
else:
|
|
1455
|
+
result.append("| " + " | ".join(["---"] * len(self.headers)) + " |")
|
|
1456
|
+
|
|
1457
|
+
for row in self.data:
|
|
1458
|
+
result.append("| " + " | ".join(str(cell) for cell in row) + " |")
|
|
1459
|
+
|
|
1460
|
+
return "\n".join(result)
|
|
1461
|
+
|
|
1462
|
+
def render_html(self, **kwargs):
|
|
1463
|
+
# HTML table implementation
|
|
1464
|
+
result = ["<table border=\"1\" style=\"border-collapse: collapse; width: 100%;\">"]
|
|
1465
|
+
|
|
1466
|
+
result.append(" <tbody>")
|
|
1467
|
+
|
|
1468
|
+
# Render headers as bold first row instead of <th> tags for Canvas compatibility
|
|
1469
|
+
if self.headers:
|
|
1470
|
+
result.append(" <tr>")
|
|
1471
|
+
for i, header in enumerate(self.headers):
|
|
1472
|
+
align_attr = ""
|
|
1473
|
+
if self.alignments and i < len(self.alignments):
|
|
1474
|
+
align_attr = f' align="{self.alignments[i]}"'
|
|
1475
|
+
# Render header as bold content in regular <td> tag
|
|
1476
|
+
rendered_header = header.render(output_format="html", **kwargs)
|
|
1477
|
+
result.append(
|
|
1478
|
+
f" <td style=\"padding: {'5px' if self.padding else '0x'}; font-weight: bold; {align_attr};\"><b>{rendered_header}</b></td>"
|
|
1479
|
+
)
|
|
1480
|
+
result.append(" </tr>")
|
|
1481
|
+
|
|
1482
|
+
# Render data rows
|
|
1483
|
+
for row in self.data:
|
|
1484
|
+
result.append(" <tr>")
|
|
1485
|
+
for i, cell in enumerate(row):
|
|
1486
|
+
if isinstance(cell, ContentAST.Element):
|
|
1487
|
+
cell = cell.render(output_format="html", **kwargs)
|
|
1488
|
+
align_attr = ""
|
|
1489
|
+
if self.alignments and i < len(self.alignments):
|
|
1490
|
+
align_attr = f' align="{self.alignments[i]}"'
|
|
1491
|
+
result.append(f" <td style=\"padding: {'5px' if self.padding else '0x'} ; {align_attr};\">{cell}</td>")
|
|
1492
|
+
result.append(" </tr>")
|
|
1493
|
+
result.append(" </tbody>")
|
|
1494
|
+
result.append("</table>")
|
|
1495
|
+
|
|
1496
|
+
return "\n".join(result)
|
|
1497
|
+
|
|
1498
|
+
def render_latex(self, **kwargs):
|
|
1499
|
+
# LaTeX table implementation
|
|
1500
|
+
if self.alignments:
|
|
1501
|
+
col_spec = "".join(
|
|
1502
|
+
"l" if a == "left" else "r" if a == "right" else "c"
|
|
1503
|
+
for a in self.alignments
|
|
1504
|
+
)
|
|
1505
|
+
else:
|
|
1506
|
+
col_spec = '|'.join(["l"] * (len(self.headers) if self.headers else len(self.data[0])))
|
|
1507
|
+
|
|
1508
|
+
result = [f"\\begin{{tabular}}{{{col_spec}}}"]
|
|
1509
|
+
if not self.hide_rules: result.append("\\toprule")
|
|
1510
|
+
|
|
1511
|
+
if self.headers:
|
|
1512
|
+
# Now all headers are ContentAST elements, so render them consistently
|
|
1513
|
+
rendered_headers = [header.render(output_format="latex", **kwargs) for header in self.headers]
|
|
1514
|
+
result.append(" & ".join(rendered_headers) + " \\\\")
|
|
1515
|
+
if not self.hide_rules: result.append("\\midrule")
|
|
1516
|
+
|
|
1517
|
+
for row in self.data:
|
|
1518
|
+
# All data cells are now ContentAST elements, so render them consistently
|
|
1519
|
+
rendered_row = [cell.render(output_format="latex", **kwargs) for cell in row]
|
|
1520
|
+
result.append(" & ".join(rendered_row) + " \\\\")
|
|
1521
|
+
|
|
1522
|
+
if len(self.data) > 1 and not self.hide_rules:
|
|
1523
|
+
result.append("\\bottomrule")
|
|
1524
|
+
result.append("\\end{tabular}")
|
|
1525
|
+
|
|
1526
|
+
return "\n\n" + "\n".join(result)
|
|
1527
|
+
|
|
1528
|
+
def render_typst(self, **kwargs):
|
|
1529
|
+
"""
|
|
1530
|
+
Render table in Typst format using native table() function.
|
|
1531
|
+
|
|
1532
|
+
Typst syntax:
|
|
1533
|
+
#table(
|
|
1534
|
+
columns: N,
|
|
1535
|
+
align: (left, center, right),
|
|
1536
|
+
[Header1], [Header2],
|
|
1537
|
+
[Cell1], [Cell2]
|
|
1538
|
+
)
|
|
1539
|
+
"""
|
|
1540
|
+
# Determine number of columns
|
|
1541
|
+
num_cols = len(self.headers) if self.headers else len(self.data[0])
|
|
1542
|
+
|
|
1543
|
+
# Build alignment specification
|
|
1544
|
+
if self.alignments:
|
|
1545
|
+
# Map alignment strings to Typst alignment
|
|
1546
|
+
align_map = {"left": "left", "right": "right", "center": "center"}
|
|
1547
|
+
aligns = [align_map.get(a, "left") for a in self.alignments]
|
|
1548
|
+
align_spec = f"align: ({', '.join(aligns)})"
|
|
1549
|
+
else:
|
|
1550
|
+
align_spec = "align: left"
|
|
1551
|
+
|
|
1552
|
+
# Start table
|
|
1553
|
+
result = [f"table("]
|
|
1554
|
+
result.append(f" columns: {num_cols},")
|
|
1555
|
+
result.append(f" {align_spec},")
|
|
1556
|
+
|
|
1557
|
+
# Add stroke if not hiding rules
|
|
1558
|
+
if not self.hide_rules:
|
|
1559
|
+
result.append(f" stroke: 0.5pt,")
|
|
1560
|
+
else:
|
|
1561
|
+
result.append(f" stroke: none,")
|
|
1562
|
+
|
|
1563
|
+
# Collect all rows (headers + data) and calculate column widths for alignment
|
|
1564
|
+
all_rows = []
|
|
1565
|
+
|
|
1566
|
+
# Render headers
|
|
1567
|
+
if self.headers:
|
|
1568
|
+
header_cells = []
|
|
1569
|
+
for header in self.headers:
|
|
1570
|
+
rendered = header.render(output_format="typst", **kwargs).strip()
|
|
1571
|
+
header_cells.append(f"[*{rendered}*]")
|
|
1572
|
+
all_rows.append(header_cells)
|
|
1573
|
+
|
|
1574
|
+
# Render data rows
|
|
1575
|
+
for row in self.data:
|
|
1576
|
+
row_cells = []
|
|
1577
|
+
for cell in row:
|
|
1578
|
+
rendered = cell.render(output_format="typst", **kwargs).strip()
|
|
1579
|
+
row_cells.append(f"[{rendered}]")
|
|
1580
|
+
all_rows.append(row_cells)
|
|
1581
|
+
|
|
1582
|
+
# Calculate max width for each column
|
|
1583
|
+
col_widths = [0] * num_cols
|
|
1584
|
+
for row in all_rows:
|
|
1585
|
+
for i, cell in enumerate(row):
|
|
1586
|
+
col_widths[i] = max(col_widths[i], len(cell))
|
|
1587
|
+
|
|
1588
|
+
# Format rows with padding
|
|
1589
|
+
for row in all_rows:
|
|
1590
|
+
padded_cells = []
|
|
1591
|
+
for i, cell in enumerate(row):
|
|
1592
|
+
padded_cells.append(cell.ljust(col_widths[i]))
|
|
1593
|
+
result.append(f" {', '.join(padded_cells)},")
|
|
1594
|
+
|
|
1595
|
+
result.append(")")
|
|
1596
|
+
|
|
1597
|
+
return "\n#box(" + "\n".join(result) + "\n)"
|
|
1598
|
+
|
|
1599
|
+
class TableGroup(Container):
|
|
1600
|
+
"""
|
|
1601
|
+
Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
|
|
1602
|
+
|
|
1603
|
+
Use this when you need to show multiple related tables together, such as
|
|
1604
|
+
multiple page tables in hierarchical paging questions. In LaTeX, tables
|
|
1605
|
+
are displayed side-by-side using minipages. In HTML/Canvas, they're stacked
|
|
1606
|
+
vertically for better mobile compatibility.
|
|
1607
|
+
|
|
1608
|
+
When to use:
|
|
1609
|
+
- Multiple related tables that should be visually grouped
|
|
1610
|
+
- Page tables in hierarchical paging
|
|
1611
|
+
- Comparison of multiple data structures
|
|
1612
|
+
|
|
1613
|
+
Features:
|
|
1614
|
+
- Automatic side-by-side layout in PDF (using minipages)
|
|
1615
|
+
- Vertical stacking in HTML for better readability
|
|
1616
|
+
- Automatic width calculation based on number of tables
|
|
1617
|
+
- Optional labels for each table
|
|
1618
|
+
|
|
1619
|
+
Example:
|
|
1620
|
+
# Create table group with labels
|
|
1621
|
+
table_group = ContentAST.TableGroup()
|
|
1622
|
+
|
|
1623
|
+
table_group.add_table(
|
|
1624
|
+
label="Page Table #0",
|
|
1625
|
+
table=ContentAST.Table(headers=["PTI", "PTE"], data=pt0_data)
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
table_group.add_table(
|
|
1629
|
+
label="Page Table #1",
|
|
1630
|
+
table=ContentAST.Table(headers=["PTI", "PTE"], data=pt1_data)
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
body.add_element(table_group)
|
|
1634
|
+
"""
|
|
1635
|
+
def __init__(self):
|
|
1636
|
+
super().__init__()
|
|
1637
|
+
self.tables = [] # List of (label, table) tuples
|
|
1638
|
+
|
|
1639
|
+
def add_table(self, table: ContentAST.Table, label: str = None):
|
|
1640
|
+
"""
|
|
1641
|
+
Add a table to the group with an optional label.
|
|
1642
|
+
|
|
1643
|
+
Args:
|
|
1644
|
+
table: ContentAST.Table to add
|
|
1645
|
+
label: Optional label to display above the table
|
|
1646
|
+
"""
|
|
1647
|
+
self.tables.append((label, table))
|
|
1648
|
+
|
|
1649
|
+
def render_html(self, **kwargs):
|
|
1650
|
+
# Stack tables vertically in HTML
|
|
1651
|
+
result = []
|
|
1652
|
+
for label, table in self.tables:
|
|
1653
|
+
if label:
|
|
1654
|
+
result.append(f"<p><b>{label}</b></p>")
|
|
1655
|
+
result.append(table.render("html", **kwargs))
|
|
1656
|
+
return "\n".join(result)
|
|
1657
|
+
|
|
1658
|
+
def render_latex(self, **kwargs):
|
|
1659
|
+
if not self.tables:
|
|
1660
|
+
return ""
|
|
1661
|
+
|
|
1662
|
+
# Calculate width based on number of tables
|
|
1663
|
+
num_tables = len(self.tables)
|
|
1664
|
+
if num_tables == 1:
|
|
1665
|
+
width = 0.9
|
|
1666
|
+
elif num_tables == 2:
|
|
1667
|
+
width = 0.45
|
|
1668
|
+
else: # 3 or more
|
|
1669
|
+
width = 0.30
|
|
1670
|
+
|
|
1671
|
+
result = ["\n\n"] # Add spacing before table group
|
|
1672
|
+
|
|
1673
|
+
for i, (label, table) in enumerate(self.tables):
|
|
1674
|
+
result.append(f"\\begin{{minipage}}{{{width}\\textwidth}}")
|
|
1675
|
+
|
|
1676
|
+
if label:
|
|
1677
|
+
# Escape # characters in labels for LaTeX
|
|
1678
|
+
escaped_label = label.replace("#", r"\#")
|
|
1679
|
+
result.append(f"\\textbf{{{escaped_label}}}")
|
|
1680
|
+
result.append("\\vspace{0.1cm}")
|
|
1681
|
+
|
|
1682
|
+
# Render the table
|
|
1683
|
+
table_latex = table.render("latex", **kwargs)
|
|
1684
|
+
result.append(table_latex)
|
|
1685
|
+
|
|
1686
|
+
result.append("\\end{minipage}")
|
|
1687
|
+
|
|
1688
|
+
# Add horizontal spacing between tables (but not after the last one)
|
|
1689
|
+
if i < num_tables - 1:
|
|
1690
|
+
result.append("\\hfill")
|
|
1691
|
+
|
|
1692
|
+
return "\n".join(result)
|
|
1693
|
+
|
|
1694
|
+
def render_typst(self, **kwargs):
|
|
1695
|
+
"""
|
|
1696
|
+
Render table group in Typst format using grid layout for side-by-side tables.
|
|
1697
|
+
|
|
1698
|
+
Uses Typst's grid() function to arrange tables horizontally with automatic
|
|
1699
|
+
column sizing and spacing.
|
|
1700
|
+
"""
|
|
1701
|
+
if not self.tables:
|
|
1702
|
+
return ""
|
|
1703
|
+
|
|
1704
|
+
num_tables = len(self.tables)
|
|
1705
|
+
|
|
1706
|
+
# Start grid with equal-width columns and some spacing
|
|
1707
|
+
result = ["\n#grid("]
|
|
1708
|
+
result.append(f" columns: {num_tables},")
|
|
1709
|
+
result.append(f" column-gutter: 1em,")
|
|
1710
|
+
result.append(f" row-gutter: 0.5em,")
|
|
1711
|
+
|
|
1712
|
+
# Add each table as a grid cell
|
|
1713
|
+
for label, table in self.tables:
|
|
1714
|
+
result.append(" [") # Start grid cell
|
|
1715
|
+
|
|
1716
|
+
if label:
|
|
1717
|
+
# Escape # characters in labels (already done by Text.render_typst)
|
|
1718
|
+
result.append(f" *{label}*")
|
|
1719
|
+
result.append(" #v(0.1cm)")
|
|
1720
|
+
result.append("") # Empty line for spacing
|
|
1721
|
+
|
|
1722
|
+
# Render the table (indent for readability)
|
|
1723
|
+
table_typst = table.render("typst", **kwargs)
|
|
1724
|
+
# Indent each line of the table
|
|
1725
|
+
indented_table = "\n".join(f" {line}" if line else "" for line in table_typst.split("\n"))
|
|
1726
|
+
result.append(indented_table)
|
|
1727
|
+
|
|
1728
|
+
result.append(" ],") # End grid cell
|
|
1729
|
+
|
|
1730
|
+
result.append(")")
|
|
1731
|
+
result.append("") # Empty line after grid
|
|
1732
|
+
|
|
1733
|
+
return "\n".join(result)
|
|
1734
|
+
|
|
1735
|
+
class AnswerBlock(Table):
|
|
1736
|
+
"""
|
|
1737
|
+
Specialized table for organizing multiple answer fields with proper spacing.
|
|
1738
|
+
|
|
1739
|
+
Creates a clean layout for multiple answer inputs with extra vertical
|
|
1740
|
+
spacing in PDF output. Inherits from Table but optimized for answers.
|
|
1741
|
+
|
|
1742
|
+
When to use:
|
|
1743
|
+
- Questions with multiple answer fields
|
|
1744
|
+
- Organized answer input sections
|
|
1745
|
+
- Better visual grouping of related answers
|
|
1746
|
+
|
|
1747
|
+
Example:
|
|
1748
|
+
# Multiple related answers
|
|
1749
|
+
answers = [
|
|
1750
|
+
ContentAST.Answer(answer=self.memory_answer, label="Memory used", unit="MB"),
|
|
1751
|
+
ContentAST.Answer(answer=self.time_answer, label="Execution time", unit="ms")
|
|
1752
|
+
]
|
|
1753
|
+
answer_block = ContentAST.AnswerBlock(answers)
|
|
1754
|
+
body.add_element(answer_block)
|
|
1755
|
+
|
|
1756
|
+
# Single answer with better spacing
|
|
1757
|
+
single_answer = ContentAST.AnswerBlock(
|
|
1758
|
+
ContentAST.Answer(answer=self.result, label="Final result")
|
|
1759
|
+
)
|
|
1760
|
+
"""
|
|
1761
|
+
def __init__(self, answers: ContentAST.Answer|List[ContentAST.Answer]):
|
|
1762
|
+
if not isinstance(answers, list):
|
|
1763
|
+
answers = [answers]
|
|
1764
|
+
|
|
1765
|
+
super().__init__(
|
|
1766
|
+
data=[
|
|
1767
|
+
[answer]
|
|
1768
|
+
for answer in answers
|
|
1769
|
+
]
|
|
1770
|
+
)
|
|
1771
|
+
self.hide_rules = True
|
|
1772
|
+
|
|
1773
|
+
def add_element(self, element):
|
|
1774
|
+
self.data.append(element)
|
|
1775
|
+
|
|
1776
|
+
def render_latex(self, **kwargs):
|
|
1777
|
+
rendered_content = super().render_latex(**kwargs)
|
|
1778
|
+
content = (
|
|
1779
|
+
r"{"
|
|
1780
|
+
r"\setlength{\extrarowheight}{20pt}"
|
|
1781
|
+
+ rendered_content +
|
|
1782
|
+
r"}"
|
|
1783
|
+
)
|
|
1784
|
+
return content
|
|
1785
|
+
|
|
1786
|
+
## Specialized Elements
|
|
1787
|
+
class RepeatedProblemPart(Container):
|
|
1788
|
+
"""
|
|
1789
|
+
Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
|
|
1790
|
+
|
|
1791
|
+
Creates the specialized alignat* LaTeX format for multipart math problems
|
|
1792
|
+
where each subpart is labeled and aligned properly. Used primarily for
|
|
1793
|
+
vector math questions that need multiple similar calculations.
|
|
1794
|
+
|
|
1795
|
+
When to use:
|
|
1796
|
+
- Questions with multiple subparts that need (a), (b), (c) labeling
|
|
1797
|
+
- Vector math problems with repeated calculations
|
|
1798
|
+
- Any math problem where subparts should be aligned
|
|
1799
|
+
|
|
1800
|
+
Features:
|
|
1801
|
+
- Automatic subpart labeling with (a), (b), (c), etc.
|
|
1802
|
+
- Proper LaTeX alignat* formatting for PDF
|
|
1803
|
+
- HTML fallback with organized layout
|
|
1804
|
+
- Flexible content support (equations, matrices, etc.)
|
|
1805
|
+
|
|
1806
|
+
Example:
|
|
1807
|
+
# Create subparts for vector dot products
|
|
1808
|
+
subparts = [
|
|
1809
|
+
(ContentAST.Matrix([[1], [2]]), "\\cdot", ContentAST.Matrix([[3], [4]])),
|
|
1810
|
+
(ContentAST.Matrix([[5], [6]]), "\\cdot", ContentAST.Matrix([[7], [8]]))
|
|
1811
|
+
]
|
|
1812
|
+
repeated_part = ContentAST.RepeatedProblemPart(subparts)
|
|
1813
|
+
body.add_element(repeated_part)
|
|
1814
|
+
"""
|
|
1815
|
+
def __init__(self, subpart_contents):
|
|
1816
|
+
"""
|
|
1817
|
+
Create a repeated problem part with multiple subquestions.
|
|
1818
|
+
|
|
1819
|
+
Args:
|
|
1820
|
+
subpart_contents: List of content for each subpart.
|
|
1821
|
+
Each item can be:
|
|
1822
|
+
- A string (rendered as equation)
|
|
1823
|
+
- A ContentAST.Element
|
|
1824
|
+
- A tuple/list of elements to be joined
|
|
1825
|
+
"""
|
|
1826
|
+
super().__init__()
|
|
1827
|
+
self.subpart_contents = subpart_contents
|
|
1828
|
+
|
|
1829
|
+
def render_markdown(self, **kwargs):
|
|
1830
|
+
result = []
|
|
1831
|
+
for i, content in enumerate(self.subpart_contents):
|
|
1832
|
+
letter = chr(ord('a') + i) # Convert to (a), (b), (c), etc.
|
|
1833
|
+
if isinstance(content, str):
|
|
1834
|
+
result.append(f"({letter}) {content}")
|
|
1835
|
+
elif isinstance(content, (list, tuple)):
|
|
1836
|
+
content_str = " ".join(str(item) for item in content)
|
|
1837
|
+
result.append(f"({letter}) {content_str}")
|
|
1838
|
+
else:
|
|
1839
|
+
result.append(f"({letter}) {str(content)}")
|
|
1840
|
+
return "\n\n".join(result)
|
|
1841
|
+
|
|
1842
|
+
def render_html(self, **kwargs):
|
|
1843
|
+
result = []
|
|
1844
|
+
for i, content in enumerate(self.subpart_contents):
|
|
1845
|
+
letter = chr(ord('a') + i)
|
|
1846
|
+
if isinstance(content, str):
|
|
1847
|
+
result.append(f"<p>({letter}) {content}</p>")
|
|
1848
|
+
elif isinstance(content, (list, tuple)):
|
|
1849
|
+
rendered_items = []
|
|
1850
|
+
for item in content:
|
|
1851
|
+
if hasattr(item, 'render'):
|
|
1852
|
+
rendered_items.append(item.render('html', **kwargs))
|
|
1853
|
+
else:
|
|
1854
|
+
rendered_items.append(str(item))
|
|
1855
|
+
content_str = " ".join(rendered_items)
|
|
1856
|
+
result.append(f"<p>({letter}) {content_str}</p>")
|
|
1857
|
+
else:
|
|
1858
|
+
if hasattr(content, 'render'):
|
|
1859
|
+
content_str = content.render('html', **kwargs)
|
|
1860
|
+
else:
|
|
1861
|
+
content_str = str(content)
|
|
1862
|
+
result.append(f"<p>({letter}) {content_str}</p>")
|
|
1863
|
+
return "\n".join(result)
|
|
1864
|
+
|
|
1865
|
+
def render_latex(self, **kwargs):
|
|
1866
|
+
if not self.subpart_contents:
|
|
1867
|
+
return ""
|
|
1868
|
+
|
|
1869
|
+
# Start alignat environment - use 2 columns for alignment
|
|
1870
|
+
result = [r"\begin{alignat*}{2}"]
|
|
1871
|
+
|
|
1872
|
+
for i, content in enumerate(self.subpart_contents):
|
|
1873
|
+
letter = chr(ord('a') + i)
|
|
1874
|
+
spacing = r"\\[6pt]" if i < len(self.subpart_contents) - 1 else r" \\"
|
|
1875
|
+
|
|
1876
|
+
if isinstance(content, str):
|
|
1877
|
+
# Treat as raw LaTeX equation content
|
|
1878
|
+
result.append(f"({letter})\\;& {content} &=&\\; {spacing}")
|
|
1879
|
+
elif isinstance(content, (list, tuple)):
|
|
1880
|
+
# Join multiple elements (e.g., matrix, operator, matrix)
|
|
1881
|
+
rendered_items = []
|
|
1882
|
+
for item in content:
|
|
1883
|
+
if hasattr(item, 'render'):
|
|
1884
|
+
rendered_items.append(item.render('latex', **kwargs))
|
|
1885
|
+
elif isinstance(item, str):
|
|
1886
|
+
rendered_items.append(item)
|
|
1887
|
+
else:
|
|
1888
|
+
rendered_items.append(str(item))
|
|
1889
|
+
content_str = " ".join(rendered_items)
|
|
1890
|
+
result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
|
|
1891
|
+
else:
|
|
1892
|
+
# Single element (ContentAST element or string)
|
|
1893
|
+
if hasattr(content, 'render'):
|
|
1894
|
+
content_str = content.render('latex', **kwargs)
|
|
1895
|
+
else:
|
|
1896
|
+
content_str = str(content)
|
|
1897
|
+
result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
|
|
1898
|
+
|
|
1899
|
+
result.append(r"\end{alignat*}")
|
|
1900
|
+
return "\n".join(result)
|
|
1901
|
+
|
|
1902
|
+
class OnlyLatex(Container):
|
|
1903
|
+
"""
|
|
1904
|
+
Container element that only renders content in LaTeX/PDF output format.
|
|
1905
|
+
|
|
1906
|
+
Use this when you need LaTeX-specific content that should not appear
|
|
1907
|
+
in HTML/Canvas or Markdown outputs. Content is completely hidden
|
|
1908
|
+
from non-LaTeX formats.
|
|
1909
|
+
|
|
1910
|
+
When to use:
|
|
1911
|
+
- LaTeX-specific formatting that has no HTML equivalent
|
|
1912
|
+
- PDF-only instructions or content
|
|
1913
|
+
- Complex LaTeX commands that break HTML rendering
|
|
1914
|
+
|
|
1915
|
+
Example:
|
|
1916
|
+
# LaTeX-only spacing or formatting
|
|
1917
|
+
latex_only = ContentAST.OnlyLatex()
|
|
1918
|
+
latex_only.add_element(ContentAST.Text("\\newpage"))
|
|
1919
|
+
|
|
1920
|
+
# Add to main content - only appears in PDF
|
|
1921
|
+
body.add_element(latex_only)
|
|
1922
|
+
"""
|
|
1923
|
+
|
|
1924
|
+
def render(self, output_format: ContentAST.OutputFormat, **kwargs):
|
|
1925
|
+
if output_format != "latex":
|
|
1926
|
+
return ""
|
|
1927
|
+
return super().render(output_format=output_format, **kwargs)
|
|
1928
|
+
|
|
1929
|
+
class OnlyHtml(Container):
|
|
1930
|
+
"""
|
|
1931
|
+
Container element that only renders content in HTML/Canvas output format.
|
|
1932
|
+
|
|
1933
|
+
Use this when you need HTML-specific content that should not appear
|
|
1934
|
+
in LaTeX/PDF or Markdown outputs. Content is completely hidden
|
|
1935
|
+
from non-HTML formats.
|
|
1936
|
+
|
|
1937
|
+
When to use:
|
|
1938
|
+
- Canvas-specific instructions or formatting
|
|
1939
|
+
- HTML-only interactive elements
|
|
1940
|
+
- Content that doesn't translate well to PDF
|
|
1941
|
+
|
|
1942
|
+
Example:
|
|
1943
|
+
# HTML-only instructions
|
|
1944
|
+
html_only = ContentAST.OnlyHtml()
|
|
1945
|
+
html_only.add_element(ContentAST.Text("Click submit when done"))
|
|
1946
|
+
|
|
1947
|
+
# Add to main content - only appears in Canvas
|
|
1948
|
+
body.add_element(html_only)
|
|
1949
|
+
"""
|
|
1950
|
+
|
|
1951
|
+
def render(self, output_format, **kwargs):
|
|
1952
|
+
if output_format != "html":
|
|
1953
|
+
return ""
|
|
1954
|
+
return super().render(output_format, **kwargs)
|
|
1955
|
+
|