QuizGenerator 0.6.2__py3-none-any.whl → 0.7.0__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.
Files changed (30) hide show
  1. QuizGenerator/contentast.py +2198 -2213
  2. QuizGenerator/misc.py +1 -1
  3. QuizGenerator/mixins.py +64 -64
  4. QuizGenerator/premade_questions/basic.py +16 -16
  5. QuizGenerator/premade_questions/cst334/languages.py +26 -26
  6. QuizGenerator/premade_questions/cst334/math_questions.py +42 -42
  7. QuizGenerator/premade_questions/cst334/memory_questions.py +124 -124
  8. QuizGenerator/premade_questions/cst334/persistence_questions.py +48 -48
  9. QuizGenerator/premade_questions/cst334/process.py +38 -38
  10. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +45 -45
  11. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +34 -34
  12. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +53 -53
  13. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +2 -2
  14. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +65 -65
  15. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +39 -39
  16. QuizGenerator/premade_questions/cst463/models/attention.py +36 -36
  17. QuizGenerator/premade_questions/cst463/models/cnns.py +26 -26
  18. QuizGenerator/premade_questions/cst463/models/rnns.py +36 -36
  19. QuizGenerator/premade_questions/cst463/models/text.py +32 -32
  20. QuizGenerator/premade_questions/cst463/models/weight_counting.py +15 -15
  21. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +124 -124
  22. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +161 -161
  23. QuizGenerator/question.py +41 -41
  24. QuizGenerator/quiz.py +7 -7
  25. QuizGenerator/typst_utils.py +2 -2
  26. {quizgenerator-0.6.2.dist-info → quizgenerator-0.7.0.dist-info}/METADATA +1 -1
  27. {quizgenerator-0.6.2.dist-info → quizgenerator-0.7.0.dist-info}/RECORD +30 -30
  28. {quizgenerator-0.6.2.dist-info → quizgenerator-0.7.0.dist-info}/WHEEL +0 -0
  29. {quizgenerator-0.6.2.dist-info → quizgenerator-0.7.0.dist-info}/entry_points.txt +0 -0
  30. {quizgenerator-0.6.2.dist-info → quizgenerator-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -24,595 +24,528 @@ import numpy as np
24
24
  log = logging.getLogger(__name__)
25
25
 
26
26
 
27
- class ContentAST:
27
+ """
28
+ Content Abstract Syntax Tree - The core content system for quiz generation.
29
+
30
+ IMPORTANT: ALWAYS use content AST elements for ALL content generation.
31
+ Never create custom LaTeX, HTML, or Markdown strings manually.
32
+
33
+ This system provides cross-format compatibility between:
34
+ - LaTeX/PDF output for printed exams
35
+ - HTML/Canvas output for online quizzes
36
+ - Markdown for documentation
37
+
38
+ Key Components:
39
+ - Section: Container for groups of elements (use for get_body/get_explanation)
40
+ - Paragraph: Text blocks that automatically handle spacing
41
+ - Equation: Mathematical equations with proper LaTeX/MathJax rendering
42
+ - Matrix: Mathematical matrices (DON'T use manual \\begin{bmatrix})
43
+ - Table: Data tables with proper formatting
44
+ - Answer: Answer input fields
45
+ - OnlyHtml/OnlyLatex: Platform-specific content
46
+
47
+ Examples:
48
+ # Good - uses content AST
49
+ body = Section()
50
+ body.add_element(Paragraph(["Calculate the matrix:"]))
51
+ matrix_data = [[1, 2], [3, 4]]
52
+ body.add_element(Matrix(data=matrix_data, bracket_type="b"))
53
+
54
+ # Bad - manual LaTeX (inconsistent, error-prone)
55
+ body.add_element(Text("\\\\begin{bmatrix} 1 & 2 \\\\\\\\ 3 & 4 \\\\end{bmatrix}"))
56
+ """
57
+
58
+ class OutputFormat(enum.StrEnum):
59
+ HTML = "html"
60
+ TYPST = "typst"
61
+ LATEX = "latex"
62
+ MARKDOWN = "markdown"
63
+
64
+ class Element(abc.ABC):
28
65
  """
29
- Content Abstract Syntax Tree - The core content system for quiz generation.
30
-
31
- IMPORTANT: ALWAYS use ContentAST elements for ALL content generation.
32
- Never create custom LaTeX, HTML, or Markdown strings manually.
33
-
34
- This system provides cross-format compatibility between:
35
- - LaTeX/PDF output for printed exams
36
- - HTML/Canvas output for online quizzes
37
- - Markdown for documentation
38
-
39
- Key Components:
40
- - ContentAST.Section: Container for groups of elements (use for get_body/get_explanation)
41
- - ContentAST.Paragraph: Text blocks that automatically handle spacing
42
- - ContentAST.Equation: Mathematical equations with proper LaTeX/MathJax rendering
43
- - ContentAST.Matrix: Mathematical matrices (DON'T use manual \\begin{bmatrix})
44
- - ContentAST.Table: Data tables with proper formatting
45
- - ContentAST.Answer: Answer input fields
46
- - ContentAST.OnlyHtml/OnlyLatex: Platform-specific content
47
-
48
- Examples:
49
- # Good - uses ContentAST
50
- body = ContentAST.Section()
51
- body.add_element(ContentAST.Paragraph(["Calculate the matrix:"]))
52
- matrix_data = [[1, 2], [3, 4]]
53
- body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="b"))
54
-
55
- # Bad - manual LaTeX (inconsistent, error-prone)
56
- body.add_element(ContentAST.Text("\\\\begin{bmatrix} 1 & 2 \\\\\\\\ 3 & 4 \\\\end{bmatrix}"))
66
+ Base class for all content AST elements providing cross-format rendering.
67
+
68
+ This is the foundation class that all content AST elements inherit from.
69
+ It provides the core rendering infrastructure that enables consistent
70
+ output across LaTeX/PDF, HTML/Canvas, and Markdown formats.
71
+
72
+ Key Features:
73
+ - Cross-format rendering (markdown, html, latex)
74
+ - Automatic format conversion via pypandoc
75
+ - Element composition and nesting
76
+ - Consistent spacing and formatting
77
+
78
+ When to inherit from Element:
79
+ - Creating new content types that need multi-format output
80
+ - Building container elements that hold other elements
81
+ - Implementing custom rendering logic for specific content types
82
+
83
+ Example usage:
84
+ # Most elements inherit from this automatically
85
+ section = Section()
86
+ section.add_element(Text("Hello world"))
87
+ section.add_element(Equation("x = 5"))
88
+
89
+ # Renders to any format
90
+ latex_output = section.render("latex")
91
+ html_output = section.render("html")
57
92
  """
93
+ def __init__(self, elements=None, add_spacing_before=False):
94
+ pass
95
+ # self.elements : List[Element] = [
96
+ # e if isinstance(e, Element) else Text(e)
97
+ # for e in (elements if elements else [])
98
+ # ]
99
+ # self.add_spacing_before = add_spacing_before
58
100
 
59
- class OutputFormat(enum.StrEnum):
60
- HTML = "html"
61
- TYPST = "typst"
62
- LATEX = "latex"
63
- MARKDOWN = "markdown"
64
-
65
- class Element(abc.ABC):
66
- """
67
- Base class for all ContentAST elements providing cross-format rendering.
68
-
69
- This is the foundation class that all ContentAST elements inherit from.
70
- It provides the core rendering infrastructure that enables consistent
71
- output across LaTeX/PDF, HTML/Canvas, and Markdown formats.
72
-
73
- Key Features:
74
- - Cross-format rendering (markdown, html, latex)
75
- - Automatic format conversion via pypandoc
76
- - Element composition and nesting
77
- - Consistent spacing and formatting
78
-
79
- When to inherit from Element:
80
- - Creating new content types that need multi-format output
81
- - Building container elements that hold other elements
82
- - Implementing custom rendering logic for specific content types
83
-
84
- Example usage:
85
- # Most elements inherit from this automatically
86
- section = ContentAST.Section()
87
- section.add_element(ContentAST.Text("Hello world"))
88
- section.add_element(ContentAST.Equation("x = 5"))
89
-
90
- # Renders to any format
91
- latex_output = section.render("latex")
92
- html_output = section.render("html")
93
- """
94
- def __init__(self, elements=None, add_spacing_before=False):
95
- pass
96
- # self.elements : List[ContentAST.Element] = [
97
- # e if isinstance(e, ContentAST.Element) else ContentAST.Text(e)
98
- # for e in (elements if elements else [])
99
- # ]
100
- # self.add_spacing_before = add_spacing_before
101
-
102
- def __str__(self):
103
- return self.render_markdown()
104
-
105
- def render(self, output_format : ContentAST.OutputFormat, **kwargs) -> str:
106
- # Render using the appropriate method, if it exists
107
- method_name = f"render_{output_format}"
108
- if hasattr(self, method_name):
109
- return getattr(self, method_name)(**kwargs)
110
-
111
- return self.render_markdown(**kwargs) # Fallback to markdown
112
-
113
- @abc.abstractmethod
114
- def render_markdown(self, **kwargs):
115
- pass
116
-
117
- @abc.abstractmethod
118
- def render_html(self, **kwargs):
119
- pass
101
+ def __str__(self):
102
+ return self.render_markdown()
103
+
104
+ def render(self, output_format : OutputFormat, **kwargs) -> str:
105
+ # Render using the appropriate method, if it exists
106
+ method_name = f"render_{output_format}"
107
+ if hasattr(self, method_name):
108
+ return getattr(self, method_name)(**kwargs)
120
109
 
121
- @abc.abstractmethod
122
- def render_latex(self, **kwargs):
123
- pass
110
+ return self.render_markdown(**kwargs) # Fallback to markdown
111
+
112
+ @abc.abstractmethod
113
+ def render_markdown(self, **kwargs):
114
+ pass
115
+
116
+ @abc.abstractmethod
117
+ def render_html(self, **kwargs):
118
+ pass
119
+
120
+ @abc.abstractmethod
121
+ def render_latex(self, **kwargs):
122
+ pass
123
+
124
+ @abc.abstractmethod
125
+ def render_typst(self, **kwargs):
126
+ pass
124
127
 
125
- @abc.abstractmethod
126
- def render_typst(self, **kwargs):
127
- pass
128
+ def is_mergeable(self, other: Element):
129
+ return False
128
130
 
129
- def is_mergeable(self, other: ContentAST.Element):
130
- return False
131
+ class Container(Element):
132
+ """Elements that contain other elements. Generally are formatting of larger pieces."""
133
+ def __init__(self, elements=None, **kwargs):
134
+ super().__init__(**kwargs)
135
+ self.elements : List[Element] = elements if elements is not None else []
131
136
 
132
- class Container(Element):
133
- """Elements that contain other elements. Generally are formatting of larger pieces."""
134
- def __init__(self, elements=None, **kwargs):
135
- super().__init__(**kwargs)
136
- self.elements : List[ContentAST.Element] = elements if elements is not None else []
137
+ def add_element(self, element):
138
+ self.elements.append(element)
139
+
140
+ def add_elements(self, elements):
141
+ self.elements.extend(elements)
142
+
143
+ @staticmethod
144
+ def render_element(element, output_format: OutputFormat, **kwargs):
145
+ if isinstance(element, Element):
146
+ return element.render(output_format, **kwargs)
147
+ log.warning(f"Element ({element}) is not Element. Defaulting to forcing to a string.")
148
+ return f"{element}"
149
+
150
+ def render_markdown(self, **kwargs):
151
+ return " ".join([
152
+ self.render_element(element, output_format=OutputFormat.MARKDOWN, **kwargs)
153
+ for element in self.elements
154
+ ])
155
+
156
+ def render_html(self, **kwargs):
157
+ for element in self.elements:
158
+ log.debug(f"element: {element}")
159
+ return " ".join([
160
+ self.render_element(element, output_format=OutputFormat.HTML, **kwargs)
161
+ for element in self.elements
162
+ ])
163
+
164
+ def render_latex(self, **kwargs):
165
+ return "".join([
166
+ self.render_element(element, output_format=OutputFormat.LATEX, **kwargs)
167
+ for element in self.elements
168
+ ])
137
169
 
138
- def add_element(self, element):
139
- self.elements.append(element)
170
+ latex = "".join(element.render("latex", **kwargs) for element in self.elements)
171
+ return f"{'\n\n\\vspace{0.5cm}' if self.add_spacing_before else ''}{latex}"
172
+
173
+ def render_typst(self, **kwargs):
174
+
175
+ return " ".join([
176
+ self.render_element(element, output_format=OutputFormat.TYPST, **kwargs)
177
+ for element in self.elements
178
+ ])
140
179
 
141
- def add_elements(self, elements):
142
- self.elements.extend(elements)
180
+ """
181
+ Default Typst rendering using markdown → typst conversion via pandoc.
182
+
183
+ This provides instant Typst support for all content AST elements without
184
+ needing explicit implementations. Override this method in subclasses
185
+ when pandoc conversion quality is insufficient or Typst-specific
186
+ features are needed.
187
+ """
188
+ # Render to markdown first
189
+ markdown_content = self.render_markdown(**kwargs)
143
190
 
144
- @staticmethod
145
- def render_element(element, output_format: ContentAST.OutputFormat, **kwargs):
146
- if isinstance(element, ContentAST.Element):
147
- return element.render(output_format, **kwargs)
148
- log.warning(f"Element ({element}) is not ContentAST.Element. Defaulting to forcing to a string.")
149
- return f"{element}"
191
+ # Convert markdown to Typst via pandoc
192
+ typst_content = self.convert_markdown(markdown_content, 'typst')
150
193
 
151
- def render_markdown(self, **kwargs):
152
- return " ".join([
153
- self.render_element(element, output_format=ContentAST.OutputFormat.MARKDOWN, **kwargs)
154
- for element in self.elements
155
- ])
156
-
157
- def render_html(self, **kwargs):
158
- for element in self.elements:
159
- log.debug(f"element: {element}")
160
- return " ".join([
161
- self.render_element(element, output_format=ContentAST.OutputFormat.HTML, **kwargs)
162
- for element in self.elements
163
- ])
164
-
165
- def render_latex(self, **kwargs):
166
- return "".join([
167
- self.render_element(element, output_format=ContentAST.OutputFormat.LATEX, **kwargs)
168
- for element in self.elements
169
- ])
170
-
171
- latex = "".join(element.render("latex", **kwargs) for element in self.elements)
172
- return f"{'\n\n\\vspace{0.5cm}' if self.add_spacing_before else ''}{latex}"
194
+ # Add spacing if needed (Typst equivalent of \vspace)
195
+ if self.add_spacing_before:
196
+ return f"\n{typst_content}"
173
197
 
174
- def render_typst(self, **kwargs):
175
-
176
- return " ".join([
177
- self.render_element(element, output_format=ContentAST.OutputFormat.TYPST, **kwargs)
178
- for element in self.elements
179
- ])
180
-
181
- """
182
- Default Typst rendering using markdown → typst conversion via pandoc.
183
-
184
- This provides instant Typst support for all ContentAST elements without
185
- needing explicit implementations. Override this method in subclasses
186
- when pandoc conversion quality is insufficient or Typst-specific
187
- features are needed.
188
- """
189
- # Render to markdown first
190
- markdown_content = self.render_markdown(**kwargs)
191
-
192
- # Convert markdown to Typst via pandoc
193
- typst_content = self.convert_markdown(markdown_content, 'typst')
194
-
195
- # Add spacing if needed (Typst equivalent of \vspace)
196
- if self.add_spacing_before:
197
- return f"\n{typst_content}"
198
-
199
- return typst_content if typst_content else markdown_content
200
-
201
- class Leaf(Element):
202
- """Elements that are just themselves."""
203
- def __init__(self, content : str, **kwargs):
204
- super().__init__(**kwargs)
205
- self.content = content
206
-
207
- @staticmethod
208
- def convert_markdown(str_to_convert, output_format : ContentAST.OutputFormat):
209
- try:
210
- match output_format:
211
-
212
- case ContentAST.OutputFormat.MARKDOWN:
213
- return str_to_convert
198
+ return typst_content if typst_content else markdown_content
199
+
200
+ class Leaf(Element):
201
+ """Elements that are just themselves."""
202
+ def __init__(self, content : str, **kwargs):
203
+ super().__init__(**kwargs)
204
+ self.content = content
205
+
206
+ @staticmethod
207
+ def convert_markdown(str_to_convert, output_format : OutputFormat):
208
+ try:
209
+ match output_format:
210
+
211
+ case OutputFormat.MARKDOWN:
212
+ return str_to_convert
213
+
214
+ case OutputFormat.HTML:
215
+ html_output = markdown.markdown(str_to_convert)
214
216
 
215
- case ContentAST.OutputFormat.HTML:
216
- html_output = markdown.markdown(str_to_convert)
217
-
218
- # Strip surrounding <p> tags so we can control paragraphs
219
- if html_output.startswith("<p>") and html_output.endswith("</p>"):
220
- html_output = html_output[3:-4]
221
-
222
- return html_output.strip()
217
+ # Strip surrounding <p> tags so we can control paragraphs
218
+ if html_output.startswith("<p>") and html_output.endswith("</p>"):
219
+ html_output = html_output[3:-4]
223
220
 
224
- case _:
225
- output = pypandoc.convert_text(
226
- str_to_convert,
227
- output_format,
228
- format='md',
229
- extra_args=["-M2GB", "+RTS", "-K64m", "-RTS"]
230
- )
231
- return output
232
- except Exception as e:
233
- log.warning(f"Specified conversion failed. Defaulting to markdown")
234
- log.warning(e)
235
-
236
- return str(str_to_convert)
237
-
238
- def render_markdown(self, **kwargs):
239
- return self.convert_markdown(self.content, ContentAST.OutputFormat.MARKDOWN)
240
-
241
- def render_html(self, **kwargs):
242
- return self.convert_markdown(self.content, ContentAST.OutputFormat.HTML)
221
+ return html_output.strip()
222
+
223
+ case _:
224
+ output = pypandoc.convert_text(
225
+ str_to_convert,
226
+ output_format,
227
+ format='md',
228
+ extra_args=["-M2GB", "+RTS", "-K64m", "-RTS"]
229
+ )
230
+ return output
231
+ except Exception as e:
232
+ log.warning(f"Specified conversion failed. Defaulting to markdown")
233
+ log.warning(e)
243
234
 
244
- def render_latex(self, **kwargs):
245
- return self.convert_markdown(self.content, ContentAST.OutputFormat.LATEX)
235
+ return str(str_to_convert)
246
236
 
247
- def render_typst(self, **kwargs):
248
- return self.convert_markdown(self.content, ContentAST.OutputFormat.TYPST) #.replace("#", r"\#")
237
+ def render_markdown(self, **kwargs):
238
+ return self.convert_markdown(self.content, OutputFormat.MARKDOWN)
249
239
 
250
- ## Top-ish Level containers
251
- class Document(Container):
252
- """
253
- Root document container for complete quiz documents with proper headers and structure.
254
-
255
- This class provides document-level rendering with appropriate headers, packages,
256
- and formatting for complete LaTeX documents. It's primarily used internally
257
- by the quiz generation system.
258
-
259
- When to use:
260
- - Creating standalone PDF documents (handled automatically by quiz system)
261
- - Need complete LaTeX document structure with packages and headers
262
- - Root container for entire quiz content
263
-
264
- Note: Most question developers should NOT use this directly.
265
- Use ContentAST.Section for question bodies and explanations instead.
266
-
267
- Features:
268
- - Complete LaTeX document headers with all necessary packages
269
- - Automatic title handling across all formats
270
- - PDF-ready formatting with proper spacing and layout
271
-
272
- Example (internal use):
273
- # Usually created automatically by quiz system
274
- doc = ContentAST.Document(title="Midterm Exam")
275
- doc.add_element(question_section)
276
- pdf_content = doc.render("latex")
277
- """
278
-
279
- LATEX_HEADER = textwrap.dedent(r"""
280
- \documentclass[12pt]{article}
281
-
282
- % Page layout
283
- \usepackage[a4paper, margin=1.5cm]{geometry}
284
-
285
- % Graphics for QR codes
286
- \usepackage{graphicx} % For including QR code images
287
-
288
- % Math packages
289
- \usepackage[leqno,fleqn]{amsmath} % For advanced math environments (matrices, equations)
290
- \setlength{\mathindent}{0pt} % flush left
291
- \usepackage{amsfonts} % For additional math fonts
292
- \usepackage{amssymb} % For additional math symbols
293
-
294
- % Tables and formatting
295
- \usepackage{booktabs} % For clean table rules
296
- \usepackage{array} % For extra column formatting options
297
- \usepackage{verbatim} % For verbatim environments (code blocks)
298
- \usepackage{enumitem} % For customized list spacing
299
- \usepackage{setspace} % For \onehalfspacing
300
-
301
- % Setting up Code environments
302
- \let\originalverbatim\verbatim
303
- \let\endoriginalverbatim\endverbatim
304
- \renewenvironment{verbatim}
305
- {\small\setlength{\baselineskip}{0.8\baselineskip}\originalverbatim}
306
- {\endoriginalverbatim}
307
-
308
- % Listings (for code)
309
- \usepackage[final]{listings}
310
- \lstset{
311
- basicstyle=\ttfamily,
312
- columns=fullflexible,
313
- frame=single,
314
- breaklines=true,
315
- postbreak=\mbox{$\hookrightarrow$\,} % You can remove or customize this
316
- }
317
-
318
- % Custom commands
319
- \newcounter{NumQuestions}
320
- \newcommand{\question}[1]{%
321
- \vspace{0.5cm}
322
- \stepcounter{NumQuestions}%
323
- \noindent\textbf{Question \theNumQuestions:} \hfill \rule{0.5cm}{0.15mm} / #1
324
- \par\vspace{0.1cm}
325
- }
326
- \newcommand{\answerblank}[1]{\rule{0pt}{10mm}\rule[-1.5mm]{#1cm}{0.15mm}}
327
-
328
- % Optional: spacing for itemized lists
329
- \setlist[itemize]{itemsep=10pt, parsep=5pt}
330
- \providecommand{\tightlist}{%
331
- \setlength{\itemsep}{10pt}\setlength{\parskip}{10pt}
332
- }
333
-
334
- \begin{document}
335
- """)
336
-
337
- TYPST_HEADER = textwrap.dedent("""
338
- #import "@preview/wrap-it:0.1.1": wrap-content
339
-
340
- // Quiz document settings
341
- #set page(
342
- paper: "us-letter",
343
- margin: 1.5cm,
344
- )
345
-
346
- #set text(
347
- size: 12pt,
348
- )
349
-
350
- // Math equation settings
351
- #set math.equation(numbering: none)
352
-
353
- // Paragraph spacing
354
- #set par(
355
- spacing: 1.0em,
356
- leading: 0.5em,
357
- )
240
+ def render_html(self, **kwargs):
241
+ return self.convert_markdown(self.content, OutputFormat.HTML)
242
+
243
+ def render_latex(self, **kwargs):
244
+ return self.convert_markdown(self.content, OutputFormat.LATEX)
245
+
246
+ def render_typst(self, **kwargs):
247
+ return self.convert_markdown(self.content, OutputFormat.TYPST) #.replace("#", r"\#")
358
248
 
359
- // Question counter and command
360
- #let question_num = counter("question")
361
-
362
- #let question(points, content, spacing: 3cm, qr_code: none) = {
363
- block(breakable: false)[
364
- #line(length: 100%, stroke: 1pt)
365
- #v(0cm)
366
- #question_num.step()
367
-
368
- *Question #context question_num.display():* (#points #if points == 1 [point] else [points])
369
- #v(0.0cm)
370
-
371
- /*
372
- #if qr_code != none {
373
- let fig = figure(image(qr_code, width: 2cm))
374
- // let fig = square(fill: teal, radius: 0.5em, width: 8em) // for debugging
375
- wrap-content(fig, align: top + right)[
376
- #h(100%) // force the wrapper to fill line width
377
- #content
378
- ]
379
- } else {
380
- content
381
- }
382
- */
383
-
384
- #grid(
385
- columns: (1fr, auto),
386
- gutter: 1em,
387
- align: top,
388
- )[
249
+ ## Top-ish Level containers
250
+ class Document(Container):
251
+ """
252
+ Root document container for complete quiz documents with proper headers and structure.
253
+
254
+ This class provides document-level rendering with appropriate headers, packages,
255
+ and formatting for complete LaTeX documents. It's primarily used internally
256
+ by the quiz generation system.
257
+
258
+ When to use:
259
+ - Creating standalone PDF documents (handled automatically by quiz system)
260
+ - Need complete LaTeX document structure with packages and headers
261
+ - Root container for entire quiz content
262
+
263
+ Note: Most question developers should NOT use this directly.
264
+ Use Section for question bodies and explanations instead.
265
+
266
+ Features:
267
+ - Complete LaTeX document headers with all necessary packages
268
+ - Automatic title handling across all formats
269
+ - PDF-ready formatting with proper spacing and layout
270
+
271
+ Example (internal use):
272
+ # Usually created automatically by quiz system
273
+ doc = Document(title="Midterm Exam")
274
+ doc.add_element(question_section)
275
+ pdf_content = doc.render("latex")
276
+ """
277
+
278
+ LATEX_HEADER = textwrap.dedent(r"""
279
+ \documentclass[12pt]{article}
280
+
281
+ % Page layout
282
+ \usepackage[a4paper, margin=1.5cm]{geometry}
283
+
284
+ % Graphics for QR codes
285
+ \usepackage{graphicx} % For including QR code images
286
+
287
+ % Math packages
288
+ \usepackage[leqno,fleqn]{amsmath} % For advanced math environments (matrices, equations)
289
+ \setlength{\mathindent}{0pt} % flush left
290
+ \usepackage{amsfonts} % For additional math fonts
291
+ \usepackage{amssymb} % For additional math symbols
292
+
293
+ % Tables and formatting
294
+ \usepackage{booktabs} % For clean table rules
295
+ \usepackage{array} % For extra column formatting options
296
+ \usepackage{verbatim} % For verbatim environments (code blocks)
297
+ \usepackage{enumitem} % For customized list spacing
298
+ \usepackage{setspace} % For \onehalfspacing
299
+
300
+ % Setting up Code environments
301
+ \let\originalverbatim\verbatim
302
+ \let\endoriginalverbatim\endverbatim
303
+ \renewenvironment{verbatim}
304
+ {\small\setlength{\baselineskip}{0.8\baselineskip}\originalverbatim}
305
+ {\endoriginalverbatim}
306
+
307
+ % Listings (for code)
308
+ \usepackage[final]{listings}
309
+ \lstset{
310
+ basicstyle=\ttfamily,
311
+ columns=fullflexible,
312
+ frame=single,
313
+ breaklines=true,
314
+ postbreak=\mbox{$\hookrightarrow$\,} % You can remove or customize this
315
+ }
316
+
317
+ % Custom commands
318
+ \newcounter{NumQuestions}
319
+ \newcommand{\question}[1]{%
320
+ \vspace{0.5cm}
321
+ \stepcounter{NumQuestions}%
322
+ \noindent\textbf{Question \theNumQuestions:} \hfill \rule{0.5cm}{0.15mm} / #1
323
+ \par\vspace{0.1cm}
324
+ }
325
+ \newcommand{\answerblank}[1]{\rule{0pt}{10mm}\rule[-1.5mm]{#1cm}{0.15mm}}
326
+
327
+ % Optional: spacing for itemized lists
328
+ \setlist[itemize]{itemsep=10pt, parsep=5pt}
329
+ \providecommand{\tightlist}{%
330
+ \setlength{\itemsep}{10pt}\setlength{\parskip}{10pt}
331
+ }
332
+
333
+ \begin{document}
334
+ """)
335
+
336
+ TYPST_HEADER = textwrap.dedent("""
337
+ #import "@preview/wrap-it:0.1.1": wrap-content
338
+
339
+ // Quiz document settings
340
+ #set page(
341
+ paper: "us-letter",
342
+ margin: 1.5cm,
343
+ )
344
+
345
+ #set text(
346
+ size: 12pt,
347
+ )
348
+
349
+ // Math equation settings
350
+ #set math.equation(numbering: none)
351
+
352
+ // Paragraph spacing
353
+ #set par(
354
+ spacing: 1.0em,
355
+ leading: 0.5em,
356
+ )
357
+
358
+ // Question counter and command
359
+ #let question_num = counter("question")
360
+
361
+ #let question(points, content, spacing: 3cm, qr_code: none) = {
362
+ block(breakable: false)[
363
+ #line(length: 100%, stroke: 1pt)
364
+ #v(0cm)
365
+ #question_num.step()
366
+
367
+ *Question #context question_num.display():* (#points #if points == 1 [point] else [points])
368
+ #v(0.0cm)
369
+
370
+ /*
371
+ #if qr_code != none {
372
+ let fig = figure(image(qr_code, width: 2cm))
373
+ // let fig = square(fill: teal, radius: 0.5em, width: 8em) // for debugging
374
+ wrap-content(fig, align: top + right)[
375
+ #h(100%) // force the wrapper to fill line width
389
376
  #content
390
- #v(spacing)
391
- ][
392
- #image(qr_code, width: 2cm)
393
377
  ]
394
- #if spacing >= 199cm {
395
-
396
- "Note: the next page is left blank for you to show work."
397
- }
398
-
378
+ } else {
379
+ content
380
+ }
381
+ */
382
+
383
+ #grid(
384
+ columns: (1fr, auto),
385
+ gutter: 1em,
386
+ align: top,
387
+ )[
388
+ #content
389
+ #v(spacing)
390
+ ][
391
+ #image(qr_code, width: 2cm)
399
392
  ]
400
- // Check if spacing >= 199cm (EXTRA_PAGE preset)
401
- // If so, add both spacing and a pagebreak for a full blank page
402
- if spacing >= 199cm {
403
-
404
- pagebreak()
405
- pagebreak()
406
- }
407
- }
393
+ #if spacing >= 199cm {
394
+
395
+ "Note: the next page is left blank for you to show work."
396
+ }
408
397
 
409
- // Fill-in line for inline answer blanks (tables, etc.)
410
- #let fillline(width: 5cm, height: 1.2em, stroke: 0.5pt) = {
411
- box(width: width, height: height, baseline: 0.25em)[
412
- #align(bottom + left)[
413
- #line(length: 100%, stroke: stroke)
414
- ]
415
- ]
416
- }
398
+ ]
399
+ // Check if spacing >= 199cm (EXTRA_PAGE preset)
400
+ // If so, add both spacing and a pagebreak for a full blank page
401
+ if spacing >= 199cm {
402
+
403
+ pagebreak()
404
+ pagebreak()
405
+ }
406
+ }
417
407
 
418
- // Code block styling
419
- #show raw.where(block: true): set text(size: 8pt)
420
- #show raw.where(block: true): block.with(
421
- fill: luma(240),
422
- inset: 10pt,
423
- radius: 4pt,
424
- )
425
- """)
426
-
427
- def __init__(self, title=None):
428
- super().__init__()
429
- self.title = title
408
+ // Fill-in line for inline answer blanks (tables, etc.)
409
+ #let fillline(width: 5cm, height: 1.2em, stroke: 0.5pt) = {
410
+ box(width: width, height: height, baseline: 0.25em)[
411
+ #align(bottom + left)[
412
+ #line(length: 100%, stroke: stroke)
413
+ ]
414
+ ]
415
+ }
416
+
417
+ // Code block styling
418
+ #show raw.where(block: true): set text(size: 8pt)
419
+ #show raw.where(block: true): block.with(
420
+ fill: luma(240),
421
+ inset: 10pt,
422
+ radius: 4pt,
423
+ )
424
+ """)
425
+
426
+ def __init__(self, title=None):
427
+ super().__init__()
428
+ self.title = title
429
+
430
+ def render(self, output_format, **kwargs):
431
+ # Generate content from all elements
432
+ content = super().render(output_format, **kwargs)
430
433
 
431
- def render(self, output_format, **kwargs):
432
- # Generate content from all elements
433
- content = super().render(output_format, **kwargs)
434
-
435
- # Add title if present
436
- if self.title and output_format == "markdown":
437
- content = f"# {self.title}\n\n{content}"
438
- elif self.title and output_format == "html":
439
- content = f"<h1>{self.title}</h1>\n{content}"
440
- elif self.title and output_format == "latex":
441
- content = f"\\section{{{self.title}}}\n{content}"
442
-
443
- return content
434
+ # Add title if present
435
+ if self.title and output_format == "markdown":
436
+ content = f"# {self.title}\n\n{content}"
437
+ elif self.title and output_format == "html":
438
+ content = f"<h1>{self.title}</h1>\n{content}"
439
+ elif self.title and output_format == "latex":
440
+ content = f"\\section{{{self.title}}}\n{content}"
444
441
 
445
- def render_latex(self, **kwargs):
446
- latex = self.LATEX_HEADER
447
- latex += f"\\title{{{self.title}}}\n"
448
- latex += textwrap.dedent(f"""
449
- \\noindent\\Large {self.title} \\hfill \\normalsize Name: \\answerblank{{{5}}}
442
+ return content
443
+
444
+ def render_latex(self, **kwargs):
445
+ latex = self.LATEX_HEADER
446
+ latex += f"\\title{{{self.title}}}\n"
447
+ latex += textwrap.dedent(f"""
448
+ \\noindent\\Large {self.title} \\hfill \\normalsize Name: \\answerblank{{{5}}}
450
449
 
451
- \\vspace{{0.5cm}}
452
- \\onehalfspacing
450
+ \\vspace{{0.5cm}}
451
+ \\onehalfspacing
453
452
 
454
- """)
453
+ """)
455
454
 
456
- latex += "\n".join(element.render(ContentAST.OutputFormat.LATEX, **kwargs) for element in self.elements)
455
+ latex += "\n".join(element.render(OutputFormat.LATEX, **kwargs) for element in self.elements)
457
456
 
458
- latex += r"\end{document}"
457
+ latex += r"\end{document}"
459
458
 
460
- return latex
459
+ return latex
461
460
 
462
- def render_typst(self, **kwargs):
463
- """Render complete Typst document with header and title"""
464
- typst = self.TYPST_HEADER
461
+ def render_typst(self, **kwargs):
462
+ """Render complete Typst document with header and title"""
463
+ typst = self.TYPST_HEADER
465
464
 
466
- # Add title and name line using grid for proper alignment
467
- typst += f"\n#grid(\n"
468
- typst += f" columns: (1fr, auto),\n"
469
- typst += f" align: (left, right),\n"
470
- typst += f" [#text(size: 14pt, weight: \"bold\")[{self.title}]],\n"
471
- typst += f" [Name: #fillline(width: 5cm)]\n"
472
- typst += f")\n"
473
- typst += f"#v(0.5cm)\n"
465
+ # Add title and name line using grid for proper alignment
466
+ typst += f"\n#grid(\n"
467
+ typst += f" columns: (1fr, auto),\n"
468
+ typst += f" align: (left, right),\n"
469
+ typst += f" [#text(size: 14pt, weight: \"bold\")[{self.title}]],\n"
470
+ typst += f" [Name: #fillline(width: 5cm)]\n"
471
+ typst += f")\n"
472
+ typst += f"#v(0.5cm)\n"
474
473
 
475
- # Render all elements
476
- typst += "".join(element.render(ContentAST.OutputFormat.TYPST, **kwargs) for element in self.elements)
477
-
478
- return typst
479
-
480
- class Question(Container):
481
- """
482
- Complete question container with body, explanation, and metadata.
474
+ # Render all elements
475
+ typst += "".join(element.render(OutputFormat.TYPST, **kwargs) for element in self.elements)
476
+
477
+ return typst
478
+
479
+ class Question(Container):
480
+ """
481
+ Complete question container with body, explanation, and metadata.
483
482
 
484
- This class represents a full question with both the question content
485
- and its explanation/solution. It handles question-level formatting
486
- like point values, spacing, and PDF layout.
483
+ This class represents a full question with both the question content
484
+ and its explanation/solution. It handles question-level formatting
485
+ like point values, spacing, and PDF layout.
487
486
 
488
- Note: Most question developers should NOT use this directly.
489
- It's created automatically by the quiz generation system.
490
- Focus on building ContentAST.Section objects for get_body() and get_explanation().
487
+ Note: Most question developers should NOT use this directly.
488
+ It's created automatically by the quiz generation system.
489
+ Focus on building Section objects for get_body() and get_explanation().
491
490
 
492
- When to use:
493
- - Creating complete question objects (handled by quiz system)
494
- - Custom question wrappers (advanced use)
491
+ When to use:
492
+ - Creating complete question objects (handled by quiz system)
493
+ - Custom question wrappers (advanced use)
495
494
 
496
- Example (internal use):
497
- # Usually created by quiz system from your question classes
498
- body = ContentAST.Section()
499
- body.add_element(ContentAST.Paragraph(["What is 2+2?"]))
495
+ Example (internal use):
496
+ # Usually created by quiz system from your question classes
497
+ body = Section()
498
+ body.add_element(Paragraph(["What is 2+2?"]))
500
499
 
501
- explanation = ContentAST.Section()
502
- explanation.add_element(ContentAST.Paragraph(["2+2=4"]))
500
+ explanation = Section()
501
+ explanation.add_element(Paragraph(["2+2=4"]))
503
502
 
504
- question = ContentAST.Question(body=body, explanation=explanation, value=5)
505
- """
503
+ question = Question(body=body, explanation=explanation, value=5)
504
+ """
505
+
506
+ def __init__(
507
+ self,
508
+ body: Section,
509
+ explanation: Section,
510
+ name=None,
511
+ value=1,
512
+ interest=1.0,
513
+ spacing=0,
514
+ topic=None,
515
+ question_number=None,
516
+ **kwargs
517
+ ):
518
+ super().__init__()
519
+ self.name = name
520
+ self.explanation = explanation
521
+ self.body = body
522
+ self.value = value
523
+ self.interest = interest
524
+ self.spacing = spacing
525
+ self.topic = topic # todo: remove this bs.
526
+ self.question_number = question_number # For QR code generation
506
527
 
507
- def __init__(
508
- self,
509
- body: ContentAST.Section,
510
- explanation: ContentAST.Section,
511
- name=None,
512
- value=1,
513
- interest=1.0,
514
- spacing=0,
515
- topic=None,
516
- question_number=None,
517
- **kwargs
518
- ):
519
- super().__init__()
520
- self.name = name
521
- self.explanation = explanation
522
- self.body = body
523
- self.value = value
524
- self.interest = interest
525
- self.spacing = spacing
526
- self.topic = topic # todo: remove this bs.
527
- self.question_number = question_number # For QR code generation
528
-
529
- self.default_kwargs = kwargs
528
+ self.default_kwargs = kwargs
529
+
530
+ def render(self, output_format, **kwargs):
531
+ updated_kwargs = self.default_kwargs
532
+ updated_kwargs.update(kwargs)
530
533
 
531
- def render(self, output_format, **kwargs):
532
- updated_kwargs = self.default_kwargs
533
- updated_kwargs.update(kwargs)
534
-
535
- log.debug(f"updated_kwargs: {updated_kwargs}")
536
-
537
- # Special handling for latex and typst - use dedicated render methods
538
- if output_format == "typst":
539
- return self.render_typst(**kwargs)
540
-
541
- # Generate content from all elements
542
- content = self.body.render(output_format, **updated_kwargs)
543
-
544
- # If output format is latex, add in minipage and question environments
545
- if output_format == "latex":
546
- # Build question header - either with or without QR code
547
- if self.question_number is not None:
548
- try:
549
- from QuizGenerator.qrcode_generator import QuestionQRCode
550
-
551
- # Build extra_data dict with regeneration metadata if available
552
- extra_data = {}
553
- if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed') and hasattr(
554
- self, 'question_version'
555
- ):
556
- if self.question_class_name and self.generation_seed is not None and self.question_version:
557
- extra_data['question_type'] = self.question_class_name
558
- extra_data['seed'] = self.generation_seed
559
- extra_data['version'] = self.question_version
560
- # Include question-specific configuration parameters if available
561
- if hasattr(self, 'config_params') and self.config_params:
562
- extra_data['config'] = self.config_params
563
-
564
- qr_path = QuestionQRCode.generate_qr_pdf(
565
- self.question_number,
566
- self.value,
567
- **extra_data
568
- )
569
- # Build custom question header with QR code centered
570
- # Format: Question N: [QR code centered] __ / points
571
- question_header = (
572
- r"\vspace{0.5cm}" + "\n"
573
- r"\noindent\textbf{Question " + str(self.question_number) + r":} \hfill "
574
- r"\rule{0.5cm}{0.15mm} / " + str(
575
- int(self.value)
576
- ) + "\n"
577
- r"\raisebox{-1cm}{" # Reduced lift to minimize extra space above
578
- rf"\includegraphics[width={QuestionQRCode.DEFAULT_SIZE_CM}cm]{{{qr_path}}}"
579
- r"} "
580
- r"\par\vspace{-1cm}"
581
- )
582
- except Exception as e:
583
- log.warning(f"Failed to generate QR code for question {self.question_number}: {e}")
584
- # Fall back to standard question macro
585
- question_header = r"\question{" + str(int(self.value)) + r"}"
586
- else:
587
- # Use standard question macro if no question number
588
- question_header = r"\question{" + str(int(self.value)) + r"}"
589
-
590
- latex_lines = [
591
- r"\noindent\begin{minipage}{\textwidth}",
592
- r"\noindent\makebox[\linewidth]{\rule{\paperwidth}{1pt}}",
593
- question_header,
594
- r"\noindent\begin{minipage}{0.9\textwidth}",
595
- content,
596
- f"\\vspace{{{self.spacing}cm}}"
597
- r"\end{minipage}",
598
- r"\end{minipage}",
599
- "\n\n",
600
- ]
601
- content = '\n'.join(latex_lines)
602
-
603
- log.debug(f"content: \n{content}")
604
-
605
- return content
534
+ log.debug(f"updated_kwargs: {updated_kwargs}")
606
535
 
607
- def render_typst(self, **kwargs):
608
- """Render question in Typst format with proper formatting"""
609
- # Render question body
610
- content = self.body.render(ContentAST.OutputFormat.TYPST, **kwargs)
611
-
612
- # Generate QR code if question number is available
613
- qr_param = ""
536
+ # Special handling for latex and typst - use dedicated render methods
537
+ if output_format == "typst":
538
+ return self.render_typst(**kwargs)
539
+
540
+ # Generate content from all elements
541
+ content = self.body.render(output_format, **updated_kwargs)
542
+
543
+ # If output format is latex, add in minipage and question environments
544
+ if output_format == "latex":
545
+ # Build question header - either with or without QR code
614
546
  if self.question_number is not None:
615
547
  try:
548
+ from QuizGenerator.qrcode_generator import QuestionQRCode
616
549
 
617
550
  # Build extra_data dict with regeneration metadata if available
618
551
  extra_data = {}
@@ -627,1765 +560,1823 @@ class ContentAST:
627
560
  if hasattr(self, 'config_params') and self.config_params:
628
561
  extra_data['config'] = self.config_params
629
562
 
630
- # Generate QR code PNG
631
563
  qr_path = QuestionQRCode.generate_qr_pdf(
632
564
  self.question_number,
633
565
  self.value,
634
- scale=1,
635
566
  **extra_data
636
567
  )
637
-
638
- # Add QR code parameter to question function call
639
- qr_param = f'qr_code: "{qr_path}"'
640
-
568
+ # Build custom question header with QR code centered
569
+ # Format: Question N: [QR code centered] __ / points
570
+ question_header = (
571
+ r"\vspace{0.5cm}" + "\n"
572
+ r"\noindent\textbf{Question " + str(self.question_number) + r":} \hfill "
573
+ r"\rule{0.5cm}{0.15mm} / " + str(
574
+ int(self.value)
575
+ ) + "\n"
576
+ r"\raisebox{-1cm}{" # Reduced lift to minimize extra space above
577
+ rf"\includegraphics[width={QuestionQRCode.DEFAULT_SIZE_CM}cm]{{{qr_path}}}"
578
+ r"} "
579
+ r"\par\vspace{-1cm}"
580
+ )
641
581
  except Exception as e:
642
582
  log.warning(f"Failed to generate QR code for question {self.question_number}: {e}")
583
+ # Fall back to standard question macro
584
+ question_header = r"\question{" + str(int(self.value)) + r"}"
585
+ else:
586
+ # Use standard question macro if no question number
587
+ question_header = r"\question{" + str(int(self.value)) + r"}"
643
588
 
644
- # Use the question function which handles all formatting including non-breaking
645
- return textwrap.dedent(f"""
646
- #question(
647
- {int(self.value)},
648
- spacing: {self.spacing}cm{'' if not qr_param else ", "}
649
- {qr_param}
650
- )[
651
- """) + content + "\n]\n\n"
652
-
653
- class Section(Container):
654
- """
655
- Primary container for question content - USE THIS for get_body() and get_explanation().
656
-
657
- This is the most important ContentAST class for question developers.
658
- It serves as the main container for organizing question content
659
- and should be the return type for your get_body() and get_explanation() methods.
660
-
661
- CRITICAL: Always use ContentAST.Section as the container for:
662
- - Question body content (return from get_body())
663
- - Question explanation/solution content (return from get_explanation())
664
- - Any grouped content that needs to render together
665
-
666
- When to use:
667
- - As the root container in get_body() and get_explanation() methods
668
- - Grouping related content elements
669
- - Organizing complex question content
670
-
671
- Example:
672
- def _get_body(self):
673
- body = ContentAST.Section()
674
- answers = []
675
- body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
676
-
677
- matrix_data = [[1, 2], [3, 4]]
678
- body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
679
-
680
- # Answer extends Leaf - add directly to body
681
- ans = ContentAST.Answer.integer("det", self.determinant, label="Determinant")
682
- answers.append(ans)
683
- body.add_element(ans)
684
- return body, answers
685
- """
686
- pass
589
+ latex_lines = [
590
+ r"\noindent\begin{minipage}{\textwidth}",
591
+ r"\noindent\makebox[\linewidth]{\rule{\paperwidth}{1pt}}",
592
+ question_header,
593
+ r"\noindent\begin{minipage}{0.9\textwidth}",
594
+ content,
595
+ f"\\vspace{{{self.spacing}cm}}"
596
+ r"\end{minipage}",
597
+ r"\end{minipage}",
598
+ "\n\n",
599
+ ]
600
+ content = '\n'.join(latex_lines)
601
+
602
+ log.debug(f"content: \n{content}")
603
+
604
+ return content
687
605
 
688
- # Individual elements
689
- class Text(Leaf):
690
- """
691
- Basic text content with automatic format conversion and selective visibility.
606
+ def render_typst(self, **kwargs):
607
+ """Render question in Typst format with proper formatting"""
608
+ # Render question body
609
+ content = self.body.render(OutputFormat.TYPST, **kwargs)
610
+
611
+ # Generate QR code if question number is available
612
+ qr_param = ""
613
+ if self.question_number is not None:
614
+ try:
615
+
616
+ # Build extra_data dict with regeneration metadata if available
617
+ extra_data = {}
618
+ if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed') and hasattr(
619
+ self, 'question_version'
620
+ ):
621
+ if self.question_class_name and self.generation_seed is not None and self.question_version:
622
+ extra_data['question_type'] = self.question_class_name
623
+ extra_data['seed'] = self.generation_seed
624
+ extra_data['version'] = self.question_version
625
+ # Include question-specific configuration parameters if available
626
+ if hasattr(self, 'config_params') and self.config_params:
627
+ extra_data['config'] = self.config_params
628
+
629
+ # Generate QR code PNG
630
+ qr_path = QuestionQRCode.generate_qr_pdf(
631
+ self.question_number,
632
+ self.value,
633
+ scale=1,
634
+ **extra_data
635
+ )
636
+
637
+ # Add QR code parameter to question function call
638
+ qr_param = f'qr_code: "{qr_path}"'
639
+
640
+ except Exception as e:
641
+ log.warning(f"Failed to generate QR code for question {self.question_number}: {e}")
642
+
643
+ # Use the question function which handles all formatting including non-breaking
644
+ return textwrap.dedent(f"""
645
+ #question(
646
+ {int(self.value)},
647
+ spacing: {self.spacing}cm{'' if not qr_param else ", "}
648
+ {qr_param}
649
+ )[
650
+ """) + content + "\n]\n\n"
651
+
652
+ class Section(Container):
653
+ """
654
+ Primary container for question content - USE THIS for get_body() and get_explanation().
655
+
656
+ This is the most important content AST class for question developers.
657
+ It serves as the main container for organizing question content
658
+ and should be the return type for your get_body() and get_explanation() methods.
659
+
660
+ CRITICAL: Always use Section as the container for:
661
+ - Question body content (return from get_body())
662
+ - Question explanation/solution content (return from get_explanation())
663
+ - Any grouped content that needs to render together
664
+
665
+ When to use:
666
+ - As the root container in get_body() and get_explanation() methods
667
+ - Grouping related content elements
668
+ - Organizing complex question content
669
+
670
+ Example:
671
+ def _get_body(self):
672
+ body = Section()
673
+ answers = []
674
+ body.add_element(Paragraph(["Calculate the determinant:"]))
675
+
676
+ matrix_data = [[1, 2], [3, 4]]
677
+ body.add_element(Matrix(data=matrix_data, bracket_type="v"))
678
+
679
+ # Answer extends Leaf - add directly to body
680
+ ans = Answer.integer("det", self.determinant, label="Determinant")
681
+ answers.append(ans)
682
+ body.add_element(ans)
683
+ return body, answers
684
+ """
685
+ pass
692
686
 
693
- This is the fundamental text element that handles plain text content
694
- with automatic markdown-to-format conversion. It supports emphasis
695
- and format-specific hiding.
687
+ # Individual elements
688
+ class Text(Leaf):
689
+ """
690
+ Basic text content with automatic format conversion and selective visibility.
696
691
 
697
- When to use:
698
- - Plain text content that needs cross-format rendering
699
- - Text that should be hidden from specific output formats
700
- - Simple text with optional emphasis
692
+ This is the fundamental text element that handles plain text content
693
+ with automatic markdown-to-format conversion. It supports emphasis
694
+ and format-specific hiding.
701
695
 
702
- DON'T use for:
703
- - Mathematical content (use ContentAST.Equation instead)
704
- - Code (use ContentAST.Code instead)
705
- - Structured content (use ContentAST.Paragraph for grouping)
696
+ When to use:
697
+ - Plain text content that needs cross-format rendering
698
+ - Text that should be hidden from specific output formats
699
+ - Simple text with optional emphasis
706
700
 
707
- Example:
708
- # Basic text
709
- text = ContentAST.Text("This is plain text")
701
+ DON'T use for:
702
+ - Mathematical content (use Equation instead)
703
+ - Code (use Code instead)
704
+ - Structured content (use Paragraph for grouping)
710
705
 
711
- # Emphasized text
712
- important = ContentAST.Text("Important note", emphasis=True)
706
+ Example:
707
+ # Basic text
708
+ text = Text("This is plain text")
713
709
 
714
- # HTML-only text (hidden from PDF)
715
- web_note = ContentAST.Text("Click submit", hide_from_latex=True)
716
- """
717
- def __init__(self, content : str, *, hide_from_latex=False, hide_from_html=False, emphasis=False):
718
- super().__init__(content)
719
- self.hide_from_latex = hide_from_latex
720
- self.hide_from_html = hide_from_html
721
- self.emphasis = emphasis
722
-
723
- def render_markdown(self, **kwargs):
724
- return f"{'***' if self.emphasis else ''}{self.content}{'***' if self.emphasis else ''}"
710
+ # Emphasized text
711
+ important = Text("Important note", emphasis=True)
725
712
 
726
- def render_html(self, **kwargs):
727
- if self.hide_from_html:
728
- return ""
729
- return self.convert_markdown(self.content,ContentAST.OutputFormat.HTML)
730
-
731
- def render_latex(self, **kwargs):
732
- if self.hide_from_latex:
733
- return ""
734
- return self.convert_markdown(self.content.replace("#", r"\#"), ContentAST.OutputFormat.LATEX)
735
-
736
- def render_typst(self, **kwargs):
737
- """Render text to Typst, escaping special characters."""
738
- if self.hide_from_latex:
739
- return ""
740
-
741
- # Extract code blocks, render them, and replace with placeholders
742
- # This prevents the # escaping from affecting content inside code blocks
743
- code_blocks = []
744
-
745
- def save_code_block(match):
746
- code_content = match.group(1).strip()
747
- # Escape quotes for Typst raw() function
748
- escaped_content = code_content.replace('"', r'\"')
749
- rendered_block = f"""
750
- #box(
751
- raw("{escaped_content}",
752
- block: true
753
- )
713
+ # HTML-only text (hidden from PDF)
714
+ web_note = Text("Click submit", hide_from_latex=True)
715
+ """
716
+ def __init__(self, content : str, *, hide_from_latex=False, hide_from_html=False, emphasis=False):
717
+ super().__init__(content)
718
+ self.hide_from_latex = hide_from_latex
719
+ self.hide_from_html = hide_from_html
720
+ self.emphasis = emphasis
721
+
722
+ def render_markdown(self, **kwargs):
723
+ return f"{'***' if self.emphasis else ''}{self.content}{'***' if self.emphasis else ''}"
724
+
725
+ def render_html(self, **kwargs):
726
+ if self.hide_from_html:
727
+ return ""
728
+ return self.convert_markdown(self.content,OutputFormat.HTML)
729
+
730
+ def render_latex(self, **kwargs):
731
+ if self.hide_from_latex:
732
+ return ""
733
+ return self.convert_markdown(self.content.replace("#", r"\#"), OutputFormat.LATEX)
734
+
735
+ def render_typst(self, **kwargs):
736
+ """Render text to Typst, escaping special characters."""
737
+ if self.hide_from_latex:
738
+ return ""
739
+
740
+ # Extract code blocks, render them, and replace with placeholders
741
+ # This prevents the # escaping from affecting content inside code blocks
742
+ code_blocks = []
743
+
744
+ def save_code_block(match):
745
+ code_content = match.group(1).strip()
746
+ # Escape quotes for Typst raw() function
747
+ escaped_content = code_content.replace('"', r'\"')
748
+ rendered_block = f"""
749
+ #box(
750
+ raw("{escaped_content}",
751
+ block: true
754
752
  )
755
- """
756
- placeholder = f"__CODE_BLOCK_{len(code_blocks)}__"
757
- code_blocks.append(rendered_block)
758
- return placeholder
759
-
760
- # Replace code blocks with placeholders
761
- content = re.sub(
762
- r"```\s*(.*)\s*```",
763
- save_code_block,
764
- self.content,
765
- flags=re.DOTALL
766
753
  )
754
+ """
755
+ placeholder = f"__CODE_BLOCK_{len(code_blocks)}__"
756
+ code_blocks.append(rendered_block)
757
+ return placeholder
758
+
759
+ # Replace code blocks with placeholders
760
+ content = re.sub(
761
+ r"```\s*(.*)\s*```",
762
+ save_code_block,
763
+ self.content,
764
+ flags=re.DOTALL
765
+ )
767
766
 
768
- # In Typst, # starts code/function calls, so we need to escape it in regular text
769
- # (but not in code blocks, which are now placeholders)
770
- content = content.replace("# ", r"\# ")
767
+ # In Typst, # starts code/function calls, so we need to escape it in regular text
768
+ # (but not in code blocks, which are now placeholders)
769
+ content = content.replace("# ", r"\# ")
771
770
 
772
- # Restore code blocks
773
- for i, block in enumerate(code_blocks):
774
- content = content.replace(f"__CODE_BLOCK_{i}__", block)
771
+ # Restore code blocks
772
+ for i, block in enumerate(code_blocks):
773
+ content = content.replace(f"__CODE_BLOCK_{i}__", block)
775
774
 
776
- if self.emphasis:
777
- content = f"*{content}*"
778
- return content
775
+ if self.emphasis:
776
+ content = f"*{content}*"
777
+ return content
779
778
 
780
- def is_mergeable(self, other: ContentAST.Element):
781
- if not isinstance(other, ContentAST.Text):
782
- return False
783
- if self.hide_from_latex != other.hide_from_latex:
784
- return False
785
- return True
786
-
787
- def merge(self, other: ContentAST.Text):
788
- self.content = self.render_markdown() + " " + other.render_markdown()
789
- self.emphasis = False
779
+ def is_mergeable(self, other: Element):
780
+ if not isinstance(other, Text):
781
+ return False
782
+ if self.hide_from_latex != other.hide_from_latex:
783
+ return False
784
+ return True
790
785
 
791
- class Code(Text):
792
- """
793
- Code block formatter with proper syntax highlighting and monospace formatting.
794
-
795
- Use this for displaying source code, terminal output, file contents,
796
- or any content that should appear in monospace font with preserved formatting.
797
-
798
- When to use:
799
- - Source code examples
800
- - Terminal/shell output
801
- - File contents or configuration
802
- - Any monospace-formatted text
803
-
804
- Features:
805
- - Automatic code block formatting in markdown
806
- - Proper HTML code styling
807
- - LaTeX verbatim environments
808
- - Preserved whitespace and line breaks
809
-
810
- Example:
811
- # Code snippet
812
- code_block = ContentAST.Code(
813
- "if (x > 0) {\n print('positive');\n}"
814
- )
815
- body.add_element(code_block)
786
+ def merge(self, other: Text):
787
+ self.content = self.render_markdown() + " " + other.render_markdown()
788
+ self.emphasis = False
816
789
 
817
- # Terminal output
818
- terminal = ContentAST.Code("$ ls -la\ntotal 24\ndrwxr-xr-x 3 user")
819
- """
820
- def __init__(self, lines, **kwargs):
821
- super().__init__(lines)
822
- self.make_normal = kwargs.get("make_normal", False)
823
-
824
- def render_markdown(self, **kwargs):
825
- content = "```" + self.content.rstrip() + "\n```"
826
- return content
827
-
828
- def render_html(self, **kwargs):
829
- return self.convert_markdown(textwrap.indent(self.content, "\t"), ContentAST.OutputFormat.HTML)
790
+ class Code(Text):
791
+ """
792
+ Code block formatter with proper syntax highlighting and monospace formatting.
793
+
794
+ Use this for displaying source code, terminal output, file contents,
795
+ or any content that should appear in monospace font with preserved formatting.
796
+
797
+ When to use:
798
+ - Source code examples
799
+ - Terminal/shell output
800
+ - File contents or configuration
801
+ - Any monospace-formatted text
802
+
803
+ Features:
804
+ - Automatic code block formatting in markdown
805
+ - Proper HTML code styling
806
+ - LaTeX verbatim environments
807
+ - Preserved whitespace and line breaks
808
+
809
+ Example:
810
+ # Code snippet
811
+ code_block = Code(
812
+ "if (x > 0) {\n print('positive');\n}"
813
+ )
814
+ body.add_element(code_block)
815
+
816
+ # Terminal output
817
+ terminal = Code("$ ls -la\ntotal 24\ndrwxr-xr-x 3 user")
818
+ """
819
+ def __init__(self, lines, **kwargs):
820
+ super().__init__(lines)
821
+ self.make_normal = kwargs.get("make_normal", False)
822
+
823
+ def render_markdown(self, **kwargs):
824
+ content = "```" + self.content.rstrip() + "\n```"
825
+ return content
826
+
827
+ def render_html(self, **kwargs):
828
+ return self.convert_markdown(textwrap.indent(self.content, "\t"), OutputFormat.HTML)
829
+
830
+ def render_latex(self, **kwargs):
831
+ return self.convert_markdown(self.render_markdown(), OutputFormat.LATEX)
832
+
833
+ def render_typst(self, **kwargs):
834
+ """Render code block in Typst with smaller monospace font."""
835
+ # Use raw block with 11pt font size
836
+ # Escape backticks in the content
837
+ escaped_content = self.content.replace("`", r"\`")
830
838
 
831
- def render_latex(self, **kwargs):
832
- return self.convert_markdown(self.render_markdown(), ContentAST.OutputFormat.LATEX)
833
-
834
- def render_typst(self, **kwargs):
835
- """Render code block in Typst with smaller monospace font."""
836
- # Use raw block with 11pt font size
837
- # Escape backticks in the content
838
- escaped_content = self.content.replace("`", r"\`")
839
-
840
- # Try to reduce individual pathway to ensure consistency
841
- return ContentAST.Text(f"```\n{escaped_content.rstrip()}\n```").render_typst()
839
+ # Try to reduce individual pathway to ensure consistency
840
+ return Text(f"```\n{escaped_content.rstrip()}\n```").render_typst()
842
841
 
843
- class Equation(Leaf):
844
- """
845
- Mathematical equation renderer with LaTeX input and cross-format output.
842
+ class Equation(Leaf):
843
+ """
844
+ Mathematical equation renderer with LaTeX input and cross-format output.
846
845
 
847
- CRITICAL: Use this for ALL mathematical content instead of manual LaTeX strings.
848
- Provides consistent math rendering across PDF (LaTeX) and Canvas (MathJax).
846
+ CRITICAL: Use this for ALL mathematical content instead of manual LaTeX strings.
847
+ Provides consistent math rendering across PDF (LaTeX) and Canvas (MathJax).
849
848
 
850
- When to use:
851
- - Any mathematical expressions, equations, or formulas
852
- - Variables, functions, mathematical notation
853
- - Both inline math (within text) and display math (separate lines)
849
+ When to use:
850
+ - Any mathematical expressions, equations, or formulas
851
+ - Variables, functions, mathematical notation
852
+ - Both inline math (within text) and display math (separate lines)
854
853
 
855
- DON'T manually write LaTeX in ContentAST.Text - always use ContentAST.Equation.
854
+ DON'T manually write LaTeX in Text - always use Equation.
856
855
 
857
- Example:
858
- # Display equation (separate line, larger)
859
- body.add_element(ContentAST.Equation("x^2 + y^2 = r^2"))
856
+ Example:
857
+ # Display equation (separate line, larger)
858
+ body.add_element(Equation("x^2 + y^2 = r^2"))
860
859
 
861
- # Inline equation (within text)
862
- paragraph = ContentAST.Paragraph([
863
- "The solution is ",
864
- ContentAST.Equation("x = \\frac{-b}{2a}", inline=True),
865
- " which can be computed easily."
866
- ])
860
+ # Inline equation (within text)
861
+ paragraph = Paragraph([
862
+ "The solution is ",
863
+ Equation("x = \\frac{-b}{2a}", inline=True),
864
+ " which can be computed easily."
865
+ ])
867
866
 
868
- # Complex equations
869
- body.add_element(ContentAST.Equation(r"\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}"))
867
+ # Complex equations
868
+ body.add_element(Equation(r"\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}"))
869
+ """
870
+ def __init__(self, latex, inline=False):
871
+ super().__init__("[equation]")
872
+ self.latex = latex
873
+ self.inline = inline
874
+
875
+ def render_markdown(self, **kwargs):
876
+ if self.inline:
877
+ return f"${self.latex}$"
878
+ else:
879
+ return r"$\displaystyle " + f"{self.latex}" + r"$"
880
+
881
+ def render_html(self, **kwargs):
882
+ if self.inline:
883
+ return fr"\({self.latex}\)"
884
+ else:
885
+ return f"<div class='math'>$$ \\displaystyle {self.latex} \\; $$</div>"
886
+
887
+ def render_latex(self, **kwargs):
888
+ if self.inline:
889
+ return f"${self.latex}$~"
890
+ else:
891
+ return f"\\begin{{flushleft}}${self.latex}$\\end{{flushleft}}"
892
+
893
+ def render_typst(self, **kwargs):
870
894
  """
871
- def __init__(self, latex, inline=False):
872
- super().__init__("[equation]")
873
- self.latex = latex
874
- self.inline = inline
875
-
876
- def render_markdown(self, **kwargs):
877
- if self.inline:
878
- return f"${self.latex}$"
879
- else:
880
- return r"$\displaystyle " + f"{self.latex}" + r"$"
881
-
882
- def render_html(self, **kwargs):
883
- if self.inline:
884
- return fr"\({self.latex}\)"
885
- else:
886
- return f"<div class='math'>$$ \\displaystyle {self.latex} \\; $$</div>"
895
+ Render equation in Typst format.
887
896
 
888
- def render_latex(self, **kwargs):
889
- if self.inline:
890
- return f"${self.latex}$~"
891
- else:
892
- return f"\\begin{{flushleft}}${self.latex}$\\end{{flushleft}}"
893
-
894
- def render_typst(self, **kwargs):
895
- """
896
- Render equation in Typst format.
897
-
898
- Typst uses LaTeX-like math syntax with $ delimiters, but with different
899
- symbol names. This method converts LaTeX math to Typst-compatible syntax.
900
- Inline: $equation$
901
- Display: $ equation $
902
- """
903
- # Convert LaTeX to Typst-compatible math
904
- typst_math = self._latex_to_typst(self.latex)
905
-
906
- if self.inline:
907
- # Inline math in Typst
908
- return f"${typst_math}$"
909
- else:
910
- # Display math in Typst
911
- return f"$ {typst_math} $"
912
-
913
- @staticmethod
914
- def _latex_to_typst(latex_str: str) -> str:
915
- r"""
916
- Convert LaTeX math syntax to Typst math syntax.
917
-
918
- Typst uses different conventions:
919
- - Greek letters: 'alpha' not '\alpha'
920
- - No \left/\right: auto-sizing parentheses
921
- - Operators: 'nabla' not '\nabla', 'times' not '\times'
922
- """
897
+ Typst uses LaTeX-like math syntax with $ delimiters, but with different
898
+ symbol names. This method converts LaTeX math to Typst-compatible syntax.
899
+ Inline: $equation$
900
+ Display: $ equation $
901
+ """
902
+ # Convert LaTeX to Typst-compatible math
903
+ typst_math = self._latex_to_typst(self.latex)
904
+
905
+ if self.inline:
906
+ # Inline math in Typst
907
+ return f"${typst_math}$"
908
+ else:
909
+ # Display math in Typst
910
+ return f"$ {typst_math} $"
911
+
912
+ @staticmethod
913
+ def _latex_to_typst(latex_str: str) -> str:
914
+ r"""
915
+ Convert LaTeX math syntax to Typst math syntax.
916
+
917
+ Typst uses different conventions:
918
+ - Greek letters: 'alpha' not '\alpha'
919
+ - No \left/\right: auto-sizing parentheses
920
+ - Operators: 'nabla' not '\nabla', 'times' not '\times'
921
+ """
923
922
 
924
- # Remove \left and \right (Typst uses auto-sizing)
925
- latex_str = latex_str.replace(r'\left', '').replace(r'\right', '')
926
-
927
- # Hat Notation
928
- latex_str = re.sub(r'\\hat{([^}]+)}', r'hat("\1")', latex_str) # \hat{...} -> hat(...)
929
-
930
- # Convert subscripts and superscripts from LaTeX to Typst
931
- # LaTeX uses braces: b_{out}, x_{10}, x^{2}
932
- # Typst uses parentheses for multi-char: b_(out), x_(10), x^(2)
933
- # Multi-character text subscripts need quotes: L_{base} -> L_("base")
934
- # But numbers don't: x_{10} -> x_(10)
935
- def convert_sub_super(match):
936
- content = match.group(1)
937
- prefix = match.group(0)[0] # '_' or '^'
938
- # If it's purely numeric or a single char, no quotes needed
939
- if content.isdigit() or len(content) == 1:
940
- return f'{prefix}({content})'
941
- # If it's multi-char text, quote it
942
- return f'{prefix}("{content}")'
943
-
944
- latex_str = re.sub(r'_{([^}]+)}', convert_sub_super, latex_str)
945
- latex_str = re.sub(r'\^{([^}]+)}', convert_sub_super, latex_str)
946
-
947
- # Convert LaTeX Greek letters to Typst syntax (remove backslash)
948
- greek_letters = [
949
- 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta',
950
- 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'pi', 'rho', 'sigma',
951
- 'tau', 'phi', 'chi', 'psi', 'omega',
952
- 'Gamma', 'Delta', 'Theta', 'Lambda', 'Xi', 'Pi', 'Sigma', 'Phi', 'Psi', 'Omega'
953
- ]
923
+ # Remove \left and \right (Typst uses auto-sizing)
924
+ latex_str = latex_str.replace(r'\left', '').replace(r'\right', '')
925
+
926
+ # Hat Notation
927
+ latex_str = re.sub(r'\\hat{([^}]+)}', r'hat("\1")', latex_str) # \hat{...} -> hat(...)
928
+
929
+ # Convert subscripts and superscripts from LaTeX to Typst
930
+ # LaTeX uses braces: b_{out}, x_{10}, x^{2}
931
+ # Typst uses parentheses for multi-char: b_(out), x_(10), x^(2)
932
+ # Multi-character text subscripts need quotes: L_{base} -> L_("base")
933
+ # But numbers don't: x_{10} -> x_(10)
934
+ def convert_sub_super(match):
935
+ content = match.group(1)
936
+ prefix = match.group(0)[0] # '_' or '^'
937
+ # If it's purely numeric or a single char, no quotes needed
938
+ if content.isdigit() or len(content) == 1:
939
+ return f'{prefix}({content})'
940
+ # If it's multi-char text, quote it
941
+ return f'{prefix}("{content}")'
942
+
943
+ latex_str = re.sub(r'_{([^}]+)}', convert_sub_super, latex_str)
944
+ latex_str = re.sub(r'\^{([^}]+)}', convert_sub_super, latex_str)
945
+
946
+ # Convert LaTeX Greek letters to Typst syntax (remove backslash)
947
+ greek_letters = [
948
+ 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta',
949
+ 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'pi', 'rho', 'sigma',
950
+ 'tau', 'phi', 'chi', 'psi', 'omega',
951
+ 'Gamma', 'Delta', 'Theta', 'Lambda', 'Xi', 'Pi', 'Sigma', 'Phi', 'Psi', 'Omega'
952
+ ]
953
+
954
+ for letter in greek_letters:
955
+ # Use word boundaries to avoid replacing parts of other commands
956
+ latex_str = re.sub(rf'\\{letter}\b', letter, latex_str)
957
+
958
+ # Convert LaTeX operators to Typst syntax
959
+ latex_str = latex_str.replace(r'\nabla', 'nabla')
960
+ latex_str = latex_str.replace(r'\times', 'times')
961
+ latex_str = latex_str.replace(r'\cdot', 'dot')
962
+ latex_str = latex_str.replace(r'\partial', 'partial')
963
+ latex_str = latex_str.replace(r'\sum', 'sum')
964
+ latex_str = latex_str.replace(r'\prod', 'product')
965
+ latex_str = latex_str.replace(r'\int', 'integral')
966
+ latex_str = latex_str.replace(r'\ln', 'ln')
967
+ latex_str = latex_str.replace(r'\log', 'log')
968
+ latex_str = latex_str.replace(r'\exp', 'exp')
969
+ latex_str = latex_str.replace(r'\sin', 'sin')
970
+ latex_str = latex_str.replace(r'\cos', 'cos')
971
+ latex_str = latex_str.replace(r'\tan', 'tan')
972
+ latex_str = latex_str.replace(r'\max', 'max')
973
+ latex_str = latex_str.replace(r'\min', 'min')
974
+ latex_str = latex_str.replace(r'\sqrt', 'sqrt')
975
+ # Convert \text{...} to "..." for Typst
976
+ latex_str = re.sub(r'\\text\{([^}]*)\}', r'"\1"', latex_str)
977
+ # Convert \frac{a}{b} to frac(a, b) for Typst
978
+ latex_str = re.sub(r'\\frac\{([^}]*)\}\{([^}]*)\}', r'frac(\1, \2)', latex_str)
979
+
980
+ # Handle matrix environments (bmatrix, pmatrix, vmatrix, Vmatrix, Bmatrix, matrix)
981
+ # Map bracket types to Typst delimiters
982
+ bracket_map = {
983
+ 'bmatrix': '\"[\"', # square brackets
984
+ 'pmatrix': '\"(\"', # parentheses (default)
985
+ 'vmatrix': '\"|\"', # single vertical bars (determinant)
986
+ 'Vmatrix': '\"||\"', # double vertical bars (norm)
987
+ 'Bmatrix': '\"{\"', # curly braces
988
+ 'matrix': None, # no brackets
989
+ }
954
990
 
955
- for letter in greek_letters:
956
- # Use word boundaries to avoid replacing parts of other commands
957
- latex_str = re.sub(rf'\\{letter}\b', letter, latex_str)
958
-
959
- # Convert LaTeX operators to Typst syntax
960
- latex_str = latex_str.replace(r'\nabla', 'nabla')
961
- latex_str = latex_str.replace(r'\times', 'times')
962
- latex_str = latex_str.replace(r'\cdot', 'dot')
963
- latex_str = latex_str.replace(r'\partial', 'partial')
964
- latex_str = latex_str.replace(r'\sum', 'sum')
965
- latex_str = latex_str.replace(r'\prod', 'product')
966
- latex_str = latex_str.replace(r'\int', 'integral')
967
- latex_str = latex_str.replace(r'\ln', 'ln')
968
- latex_str = latex_str.replace(r'\log', 'log')
969
- latex_str = latex_str.replace(r'\exp', 'exp')
970
- latex_str = latex_str.replace(r'\sin', 'sin')
971
- latex_str = latex_str.replace(r'\cos', 'cos')
972
- latex_str = latex_str.replace(r'\tan', 'tan')
973
- latex_str = latex_str.replace(r'\max', 'max')
974
- latex_str = latex_str.replace(r'\min', 'min')
975
- latex_str = latex_str.replace(r'\sqrt', 'sqrt')
976
- # Convert \text{...} to "..." for Typst
977
- latex_str = re.sub(r'\\text\{([^}]*)\}', r'"\1"', latex_str)
978
- # Convert \frac{a}{b} to frac(a, b) for Typst
979
- latex_str = re.sub(r'\\frac\{([^}]*)\}\{([^}]*)\}', r'frac(\1, \2)', latex_str)
980
-
981
- # Handle matrix environments (bmatrix, pmatrix, vmatrix, Vmatrix, Bmatrix, matrix)
982
- # Map bracket types to Typst delimiters
983
- bracket_map = {
984
- 'bmatrix': '\"[\"', # square brackets
985
- 'pmatrix': '\"(\"', # parentheses (default)
986
- 'vmatrix': '\"|\"', # single vertical bars (determinant)
987
- 'Vmatrix': '\"||\"', # double vertical bars (norm)
988
- 'Bmatrix': '\"{\"', # curly braces
989
- 'matrix': None, # no brackets
990
- }
991
+ for env_type, delim in bracket_map.items():
992
+ pattern = rf'\\begin\{{{env_type}\}}(.*?)\\end\{{{env_type}\}}'
991
993
 
992
- for env_type, delim in bracket_map.items():
993
- pattern = rf'\\begin\{{{env_type}\}}(.*?)\\end\{{{env_type}\}}'
994
-
995
- def make_replacer(delimiter):
996
- def replace_matrix(match):
997
- content = match.group(1)
998
- # Split rows by \\ and columns by &
999
- rows = content.split(r'\\')
1000
- rows = [r.strip() for r in rows if r.strip()]
1001
-
1002
- # Check if it's a vector (single column) or matrix
1003
- is_vector = all('&' not in row for row in rows)
1004
-
1005
- if is_vector:
1006
- # Single column - use semicolons to separate rows in mat()
1007
- elements = "; ".join(rows)
1008
- else:
1009
- # Multiple columns - replace & with , and rows with ;
1010
- formatted_rows = [row.replace('&', ',').strip() for row in rows]
1011
- elements = "; ".join(formatted_rows)
1012
-
1013
- if delimiter:
1014
- return f"mat(delim: {delimiter}, {elements})"
1015
- else:
1016
- return f"mat({elements})"
1017
- return replace_matrix
1018
-
1019
- latex_str = re.sub(pattern, make_replacer(delim), latex_str, flags=re.DOTALL)
1020
-
1021
- # Handle \left\| ... \right\| for norms
1022
- latex_str = re.sub(r'\\\|', '||', latex_str)
1023
-
1024
- return latex_str
1025
-
1026
- @classmethod
1027
- def make_block_equation__multiline_equals(cls, lhs : str, rhs : List[str]):
1028
- equation_lines = []
1029
- equation_lines.extend([
1030
- r"\begin{array}{l}",
1031
- f"{lhs} = {rhs[0]} \\\\",
1032
- ])
1033
- equation_lines.extend([
1034
- f"\\phantom{{{lhs}}} = {eq} \\\\"
1035
- for eq in rhs[1:]
1036
- ])
1037
- equation_lines.extend([
1038
- r"\end{array}",
1039
- ])
1040
-
1041
- return cls('\n'.join(equation_lines))
994
+ def make_replacer(delimiter):
995
+ def replace_matrix(match):
996
+ content = match.group(1)
997
+ # Split rows by \\ and columns by &
998
+ rows = content.split(r'\\')
999
+ rows = [r.strip() for r in rows if r.strip()]
1042
1000
 
1043
- class MathExpression(Leaf):
1044
- """
1045
- Compose multiple math elements into a single format-independent expression.
1046
-
1047
- This allows mixing ContentAST elements (like Matrix) with math operators
1048
- and symbols, with each part rendering appropriately for the target format.
1049
-
1050
- Example:
1051
- # Vector magnitude: ||v|| =
1052
- body.add_element(ContentAST.MathExpression([
1053
- "||",
1054
- ContentAST.Matrix(data=[[1], [2], [3]], bracket_type="b"),
1055
- "|| = "
1056
- ]))
1057
-
1058
- # Vector addition: a + b =
1059
- body.add_element(ContentAST.MathExpression([
1060
- ContentAST.Matrix(data=[[1], [2]], bracket_type="b"),
1061
- " + ",
1062
- ContentAST.Matrix(data=[[3], [4]], bracket_type="b"),
1063
- " = "
1064
- ]))
1065
-
1066
- Parts can be:
1067
- - Strings: rendered as-is (math operators, symbols, etc.)
1068
- - ContentAST elements: call their render method for the target format
1069
- """
1070
- def __init__(self, parts, inline=False):
1071
- super().__init__("[math_expression]")
1072
- self.parts = parts # List of strings and/or ContentAST elements
1073
- self.inline = inline
1074
-
1075
- def _render_parts(self, output_format, **kwargs):
1076
- """Render all parts for the given output format."""
1077
- rendered = []
1078
- for part in self.parts:
1079
- if isinstance(part, str):
1080
- # Convert LaTeX operators to Typst if needed
1081
- if output_format == ContentAST.OutputFormat.TYPST:
1082
- part = part.replace(r'\cdot', ' dot ')
1083
- part = part.replace(r'\times', ' times ')
1084
- part = part.replace(r'\div', ' div ')
1085
- rendered.append(part)
1086
- elif isinstance(part, ContentAST.Element):
1087
- # Use dedicated math_content methods if available (cleaner than stripping delimiters)
1088
- if output_format == ContentAST.OutputFormat.HTML:
1089
- # For HTML (MathJax), use LaTeX math content
1090
- if hasattr(part, 'math_content_latex'):
1091
- rendered.append(part.math_content_latex())
1092
- else:
1093
- # Fallback: try to extract from render_html
1094
- html = part.render_html(**kwargs)
1095
- html = re.sub(r"<[^>]+>", "", html)
1096
- html = re.sub(r"^\$\$?\s*\\displaystyle\s*", "", html)
1097
- html = re.sub(r"^\$\$?\s*", "", html)
1098
- html = re.sub(r"\s*\$\$?$", "", html)
1099
- rendered.append(html)
1100
- elif output_format == ContentAST.OutputFormat.TYPST:
1101
- if hasattr(part, 'math_content_typst'):
1102
- rendered.append(part.math_content_typst())
1103
- else:
1104
- # Fallback: try to extract from render_typst
1105
- typst = part.render_typst(**kwargs)
1106
- typst = re.sub(r"^\s*\$\s*", "", typst)
1107
- typst = re.sub(r"\s*\$\s*$", "", typst)
1108
- rendered.append(typst)
1109
- elif output_format == ContentAST.OutputFormat.LATEX:
1110
- if hasattr(part, 'math_content_latex'):
1111
- rendered.append(part.math_content_latex())
1112
- else:
1113
- latex = part.render_latex(**kwargs)
1114
- latex = re.sub(r"^\\begin\{flushleft\}", "", latex)
1115
- latex = re.sub(r"\\end\{flushleft\}$", "", latex)
1116
- latex = re.sub(r"^\\\[", "", latex)
1117
- latex = re.sub(r"\\\]$", "", latex)
1118
- latex = re.sub(r"^\$", "", latex)
1119
- latex = re.sub(r"\$~?$", "", latex)
1120
- rendered.append(latex)
1121
- elif output_format == ContentAST.OutputFormat.MARKDOWN:
1122
- if hasattr(part, 'math_content_latex'):
1123
- rendered.append(part.math_content_latex())
1124
- else:
1125
- md = part.render_markdown(**kwargs)
1126
- md = re.sub(r"^\$", "", md)
1127
- md = re.sub(r"\$$", "", md)
1128
- rendered.append(md)
1129
- else:
1130
- # Convert to string as fallback
1131
- rendered.append(str(part))
1132
- return "".join(rendered)
1133
-
1134
- def render_markdown(self, **kwargs):
1135
- content = self._render_parts(ContentAST.OutputFormat.MARKDOWN, **kwargs)
1136
- if self.inline:
1137
- return f"${content}$"
1138
- else:
1139
- return f"$\\displaystyle {content}$"
1001
+ # Check if it's a vector (single column) or matrix
1002
+ is_vector = all('&' not in row for row in rows)
1140
1003
 
1141
- def render_html(self, **kwargs):
1142
- content = self._render_parts(ContentAST.OutputFormat.HTML, **kwargs)
1143
- if self.inline:
1144
- return f"\\({content}\\)"
1145
- else:
1146
- return f"<div class='math'>$$ \\displaystyle {content} \\; $$</div>"
1004
+ if is_vector:
1005
+ # Single column - use semicolons to separate rows in mat()
1006
+ elements = "; ".join(rows)
1007
+ else:
1008
+ # Multiple columns - replace & with , and rows with ;
1009
+ formatted_rows = [row.replace('&', ',').strip() for row in rows]
1010
+ elements = "; ".join(formatted_rows)
1147
1011
 
1148
- def render_latex(self, **kwargs):
1149
- content = self._render_parts(ContentAST.OutputFormat.LATEX, **kwargs)
1150
- if self.inline:
1151
- return f"${content}$~"
1152
- else:
1153
- return f"\\begin{{flushleft}}${content}$\\end{{flushleft}}"
1012
+ if delimiter:
1013
+ return f"mat(delim: {delimiter}, {elements})"
1014
+ else:
1015
+ return f"mat({elements})"
1016
+ return replace_matrix
1017
+
1018
+ latex_str = re.sub(pattern, make_replacer(delim), latex_str, flags=re.DOTALL)
1019
+
1020
+ # Handle \left\| ... \right\| for norms
1021
+ latex_str = re.sub(r'\\\|', '||', latex_str)
1022
+
1023
+ return latex_str
1024
+
1025
+ @classmethod
1026
+ def make_block_equation__multiline_equals(cls, lhs : str, rhs : List[str]):
1027
+ equation_lines = []
1028
+ equation_lines.extend([
1029
+ r"\begin{array}{l}",
1030
+ f"{lhs} = {rhs[0]} \\\\",
1031
+ ])
1032
+ equation_lines.extend([
1033
+ f"\\phantom{{{lhs}}} = {eq} \\\\"
1034
+ for eq in rhs[1:]
1035
+ ])
1036
+ equation_lines.extend([
1037
+ r"\end{array}",
1038
+ ])
1039
+
1040
+ return cls('\n'.join(equation_lines))
1154
1041
 
1155
- def render_typst(self, **kwargs):
1156
- content = self._render_parts(ContentAST.OutputFormat.TYPST, **kwargs)
1157
- if self.inline:
1158
- return f"${content}$"
1042
+ class MathExpression(Leaf):
1043
+ """
1044
+ Compose multiple math elements into a single format-independent expression.
1045
+
1046
+ This allows mixing content AST elements (like Matrix) with math operators
1047
+ and symbols, with each part rendering appropriately for the target format.
1048
+
1049
+ Example:
1050
+ # Vector magnitude: ||v|| =
1051
+ body.add_element(MathExpression([
1052
+ "||",
1053
+ Matrix(data=[[1], [2], [3]], bracket_type="b"),
1054
+ "|| = "
1055
+ ]))
1056
+
1057
+ # Vector addition: a + b =
1058
+ body.add_element(MathExpression([
1059
+ Matrix(data=[[1], [2]], bracket_type="b"),
1060
+ " + ",
1061
+ Matrix(data=[[3], [4]], bracket_type="b"),
1062
+ " = "
1063
+ ]))
1064
+
1065
+ Parts can be:
1066
+ - Strings: rendered as-is (math operators, symbols, etc.)
1067
+ - content AST elements: call their render method for the target format
1068
+ """
1069
+ def __init__(self, parts, inline=False):
1070
+ super().__init__("[math_expression]")
1071
+ self.parts = parts # List of strings and/or content AST elements
1072
+ self.inline = inline
1073
+
1074
+ def _render_parts(self, output_format, **kwargs):
1075
+ """Render all parts for the given output format."""
1076
+ rendered = []
1077
+ for part in self.parts:
1078
+ if isinstance(part, str):
1079
+ # Convert LaTeX operators to Typst if needed
1080
+ if output_format == OutputFormat.TYPST:
1081
+ part = part.replace(r'\cdot', ' dot ')
1082
+ part = part.replace(r'\times', ' times ')
1083
+ part = part.replace(r'\div', ' div ')
1084
+ rendered.append(part)
1085
+ elif isinstance(part, Element):
1086
+ # Use dedicated math_content methods if available (cleaner than stripping delimiters)
1087
+ if output_format == OutputFormat.HTML:
1088
+ # For HTML (MathJax), use LaTeX math content
1089
+ if hasattr(part, 'math_content_latex'):
1090
+ rendered.append(part.math_content_latex())
1091
+ else:
1092
+ # Fallback: try to extract from render_html
1093
+ html = part.render_html(**kwargs)
1094
+ html = re.sub(r"<[^>]+>", "", html)
1095
+ html = re.sub(r"^\$\$?\s*\\displaystyle\s*", "", html)
1096
+ html = re.sub(r"^\$\$?\s*", "", html)
1097
+ html = re.sub(r"\s*\$\$?$", "", html)
1098
+ rendered.append(html)
1099
+ elif output_format == OutputFormat.TYPST:
1100
+ if hasattr(part, 'math_content_typst'):
1101
+ rendered.append(part.math_content_typst())
1102
+ else:
1103
+ # Fallback: try to extract from render_typst
1104
+ typst = part.render_typst(**kwargs)
1105
+ typst = re.sub(r"^\s*\$\s*", "", typst)
1106
+ typst = re.sub(r"\s*\$\s*$", "", typst)
1107
+ rendered.append(typst)
1108
+ elif output_format == OutputFormat.LATEX:
1109
+ if hasattr(part, 'math_content_latex'):
1110
+ rendered.append(part.math_content_latex())
1111
+ else:
1112
+ latex = part.render_latex(**kwargs)
1113
+ latex = re.sub(r"^\\begin\{flushleft\}", "", latex)
1114
+ latex = re.sub(r"\\end\{flushleft\}$", "", latex)
1115
+ latex = re.sub(r"^\\\[", "", latex)
1116
+ latex = re.sub(r"\\\]$", "", latex)
1117
+ latex = re.sub(r"^\$", "", latex)
1118
+ latex = re.sub(r"\$~?$", "", latex)
1119
+ rendered.append(latex)
1120
+ elif output_format == OutputFormat.MARKDOWN:
1121
+ if hasattr(part, 'math_content_latex'):
1122
+ rendered.append(part.math_content_latex())
1123
+ else:
1124
+ md = part.render_markdown(**kwargs)
1125
+ md = re.sub(r"^\$", "", md)
1126
+ md = re.sub(r"\$$", "", md)
1127
+ rendered.append(md)
1159
1128
  else:
1160
- return f" $ {content} $ "
1161
-
1162
- class Matrix(Leaf):
1163
- """
1164
- Mathematical matrix renderer for consistent cross-format display.
1129
+ # Convert to string as fallback
1130
+ rendered.append(str(part))
1131
+ return "".join(rendered)
1132
+
1133
+ def render_markdown(self, **kwargs):
1134
+ content = self._render_parts(OutputFormat.MARKDOWN, **kwargs)
1135
+ if self.inline:
1136
+ return f"${content}$"
1137
+ else:
1138
+ return f"$\\displaystyle {content}$"
1139
+
1140
+ def render_html(self, **kwargs):
1141
+ content = self._render_parts(OutputFormat.HTML, **kwargs)
1142
+ if self.inline:
1143
+ return f"\\({content}\\)"
1144
+ else:
1145
+ return f"<div class='math'>$$ \\displaystyle {content} \\; $$</div>"
1146
+
1147
+ def render_latex(self, **kwargs):
1148
+ content = self._render_parts(OutputFormat.LATEX, **kwargs)
1149
+ if self.inline:
1150
+ return f"${content}$~"
1151
+ else:
1152
+ return f"\\begin{{flushleft}}${content}$\\end{{flushleft}}"
1153
+
1154
+ def render_typst(self, **kwargs):
1155
+ content = self._render_parts(OutputFormat.TYPST, **kwargs)
1156
+ if self.inline:
1157
+ return f"${content}$"
1158
+ else:
1159
+ return f" $ {content} $ "
1160
+
1161
+ class Matrix(Leaf):
1162
+ """
1163
+ Mathematical matrix renderer for consistent cross-format display.
1165
1164
 
1166
- CRITICAL: Use this for ALL matrix and vector notation instead of manual LaTeX.
1165
+ CRITICAL: Use this for ALL matrix and vector notation instead of manual LaTeX.
1167
1166
 
1168
- DON'T do this:
1169
- # Manual LaTeX (error-prone, inconsistent)
1170
- latex_str = f"\\\\begin{{bmatrix}} {a} & {b} \\\\\\\\ {c} & {d} \\\\end{{bmatrix}}"
1167
+ DON'T do this:
1168
+ # Manual LaTeX (error-prone, inconsistent)
1169
+ latex_str = f"\\\\begin{{bmatrix}} {a} & {b} \\\\\\\\ {c} & {d} \\\\end{{bmatrix}}"
1171
1170
 
1172
- DO this instead:
1173
- # ContentAST.Matrix (consistent, cross-format)
1174
- matrix_data = [[a, b], [c, d]]
1175
- ContentAST.Matrix(data=matrix_data, bracket_type="b")
1171
+ DO this instead:
1172
+ # Matrix (consistent, cross-format)
1173
+ matrix_data = [[a, b], [c, d]]
1174
+ Matrix(data=matrix_data, bracket_type="b")
1176
1175
 
1177
- For vectors (single column matrices):
1178
- vector_data = [[v1], [v2], [v3]] # Note: list of single-element lists
1179
- ContentAST.Matrix(data=vector_data, bracket_type="b")
1176
+ For vectors (single column matrices):
1177
+ vector_data = [[v1], [v2], [v3]] # Note: list of single-element lists
1178
+ Matrix(data=vector_data, bracket_type="b")
1180
1179
 
1181
- For LaTeX strings in equations:
1182
- matrix_latex = ContentAST.Matrix.to_latex(matrix_data, "b")
1183
- ContentAST.Equation(f"A = {matrix_latex}")
1180
+ For LaTeX strings in equations:
1181
+ matrix_latex = Matrix.to_latex(matrix_data, "b")
1182
+ Equation(f"A = {matrix_latex}")
1184
1183
 
1185
- Bracket types:
1186
- - "b": square brackets [matrix] - most common for vectors/matrices
1187
- - "p": parentheses (matrix) - sometimes used for matrices
1188
- - "v": vertical bars |matrix| - for determinants
1189
- - "B": curly braces {matrix}
1190
- - "V": double vertical bars ||matrix|| - for norms
1184
+ Bracket types:
1185
+ - "b": square brackets [matrix] - most common for vectors/matrices
1186
+ - "p": parentheses (matrix) - sometimes used for matrices
1187
+ - "v": vertical bars |matrix| - for determinants
1188
+ - "B": curly braces {matrix}
1189
+ - "V": double vertical bars ||matrix|| - for norms
1190
+ """
1191
+ def __init__(self, data, *, bracket_type="p", inline=False, name=None):
1191
1192
  """
1192
- def __init__(self, data, *, bracket_type="p", inline=False, name=None):
1193
- """
1194
- Creates a matrix element that renders consistently across output formats.
1195
-
1196
- Args:
1197
- data: Matrix data as List[List[numbers/strings]] or numpy ndarray (1D or 2D)
1198
- For vectors: [[v1], [v2], [v3]] (column vector) or np.array([v1, v2, v3])
1199
- For matrices: [[a, b], [c, d]] or np.array([[a, b], [c, d]])
1200
- bracket_type: Bracket style - "b" for [], "p" for (), "v" for |, etc.
1201
- inline: Whether to use inline (smaller) matrix formatting
1202
- """
1203
- super().__init__("[matrix]")
1204
-
1205
- # Convert numpy ndarray to list format if needed
1206
- if isinstance(data, np.ndarray):
1207
- if data.ndim == 1:
1208
- # 1D array: convert to column vector [[v1], [v2], [v3]]
1209
- self.data = [[val] for val in data]
1210
- elif data.ndim == 2:
1211
- # 2D array: convert to list of lists
1212
- self.data = data.tolist()
1213
- else:
1214
- raise ValueError(f"Matrix only supports 1D or 2D arrays, got {data.ndim}D")
1193
+ Creates a matrix element that renders consistently across output formats.
1194
+
1195
+ Args:
1196
+ data: Matrix data as List[List[numbers/strings]] or numpy ndarray (1D or 2D)
1197
+ For vectors: [[v1], [v2], [v3]] (column vector) or np.array([v1, v2, v3])
1198
+ For matrices: [[a, b], [c, d]] or np.array([[a, b], [c, d]])
1199
+ bracket_type: Bracket style - "b" for [], "p" for (), "v" for |, etc.
1200
+ inline: Whether to use inline (smaller) matrix formatting
1201
+ """
1202
+ super().__init__("[matrix]")
1203
+
1204
+ # Convert numpy ndarray to list format if needed
1205
+ if isinstance(data, np.ndarray):
1206
+ if data.ndim == 1:
1207
+ # 1D array: convert to column vector [[v1], [v2], [v3]]
1208
+ self.data = [[val] for val in data]
1209
+ elif data.ndim == 2:
1210
+ # 2D array: convert to list of lists
1211
+ self.data = data.tolist()
1215
1212
  else:
1216
- self.data = data
1213
+ raise ValueError(f"Matrix only supports 1D or 2D arrays, got {data.ndim}D")
1214
+ else:
1215
+ self.data = data
1217
1216
 
1218
- self.bracket_type = bracket_type
1219
- self.inline = inline
1220
- self.name = name
1217
+ self.bracket_type = bracket_type
1218
+ self.inline = inline
1219
+ self.name = name
1221
1220
 
1222
- @staticmethod
1223
- def to_latex(data, bracket_type="p"):
1224
- """
1225
- Convert matrix data to LaTeX string for use in equations.
1221
+ @staticmethod
1222
+ def to_latex(data, bracket_type="p"):
1223
+ """
1224
+ Convert matrix data to LaTeX string for use in equations.
1226
1225
 
1227
- Use this when you need a LaTeX string to embed in ContentAST.Equation:
1228
- matrix_latex = ContentAST.Matrix.to_latex([[1, 2], [3, 4]], "b")
1229
- ContentAST.Equation(f"A = {matrix_latex}")
1226
+ Use this when you need a LaTeX string to embed in Equation:
1227
+ matrix_latex = Matrix.to_latex([[1, 2], [3, 4]], "b")
1228
+ Equation(f"A = {matrix_latex}")
1230
1229
 
1231
- Args:
1232
- data: Matrix data as List[List[numbers/strings]]
1233
- bracket_type: Bracket style ("b", "p", "v", etc.)
1230
+ Args:
1231
+ data: Matrix data as List[List[numbers/strings]]
1232
+ bracket_type: Bracket style ("b", "p", "v", etc.)
1234
1233
 
1235
- Returns:
1236
- str: LaTeX matrix string (e.g., "\\begin{bmatrix} 1 & 2 \\\\ 3 & 4 \\end{bmatrix}")
1237
- """
1238
- rows = []
1239
- for row in data:
1240
- rows.append(" & ".join(str(cell) for cell in row))
1241
- matrix_content = r" \\ ".join(rows)
1242
- return f"\\begin{{{bracket_type}matrix}} {matrix_content} \\end{{{bracket_type}matrix}}"
1243
-
1244
- def math_content_latex(self):
1245
- """Return raw LaTeX math content without delimiters (for use in MathExpression)."""
1246
- return ContentAST.Matrix.to_latex(self.data, self.bracket_type)
1247
-
1248
- def math_content_typst(self):
1249
- """Return raw Typst math content without delimiters (for use in MathExpression)."""
1250
- # Build matrix content
1251
- rows = []
1252
- for row in self.data:
1253
- rows.append(", ".join(str(cell) for cell in row))
1254
-
1255
- # Check if it's a vector (single column)
1256
- is_vector = all(len(row) == 1 for row in self.data)
1257
-
1258
- if is_vector:
1259
- # Use vec() for vectors
1260
- matrix_content = ", ".join(str(row[0]) for row in self.data)
1261
- result = f"vec({matrix_content})"
1262
- else:
1263
- # Use mat() for matrices with semicolons separating rows
1264
- matrix_content = "; ".join(rows)
1265
- result = f"mat({matrix_content})"
1266
-
1267
- # Add bracket delimiters if needed
1268
- if self.bracket_type == "b": # square brackets
1269
- matrix_content_inner = ", ".join(str(row[0]) for row in self.data) if is_vector else "; ".join(rows)
1270
- result = f"mat(delim: \"[\", {matrix_content_inner})" if not is_vector else f"vec(delim: \"[\", {matrix_content_inner})"
1271
- elif self.bracket_type == "v": # vertical bars (determinant)
1272
- result = f"mat(delim: \"|\", {'; '.join(rows)})"
1273
- elif self.bracket_type == "V": # double vertical bars (norm)
1274
- result = f"mat(delim: \"||\", {'; '.join(rows)})"
1275
- elif self.bracket_type == "B": # curly braces
1276
- result = f"mat(delim: \"{{\", {'; '.join(rows)})"
1277
-
1278
- return result
1279
-
1280
- def render_markdown(self, **kwargs):
1281
- matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1282
- rows = []
1283
- for row in self.data:
1284
- rows.append(" & ".join(str(cell) for cell in row))
1285
- matrix_content = r" \\ ".join(rows)
1286
-
1287
- if self.inline and self.bracket_type == "p":
1288
- return f"$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$"
1289
- else:
1290
- return f"$\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}$"
1291
-
1292
- def render_html(self, **kwargs):
1293
- matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1294
- rows = []
1295
- if isinstance(self.data, np.ndarray):
1296
- data = self.data.tolist()
1297
- else:
1298
- data = self.data
1299
- for row in data:
1300
- rows.append(" & ".join(str(cell) for cell in row))
1301
- matrix_content = r" \\ ".join(rows)
1302
-
1303
- if self.inline:
1304
- return f"<span class='math'>$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$</span>"
1305
- else:
1306
- name_str = f"\\text{{{self.name}}} = " if self.name else ""
1307
- return f"<div class='math'>$${name_str}\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}$$</div>"
1308
-
1309
- def render_latex(self, **kwargs):
1310
- matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1311
- rows = []
1312
- for row in self.data:
1313
- rows.append(" & ".join(str(cell) for cell in row))
1314
- matrix_content = r" \\ ".join(rows)
1315
-
1316
- if self.inline and self.bracket_type == "p":
1317
- return f"$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$"
1318
- else:
1319
- return f"\\[\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\]"
1320
-
1321
- def render_typst(self, **kwargs):
1322
- """Render matrix in Typst format using mat() and vec() functions."""
1323
- # Build matrix content with semicolons separating rows
1324
- rows = []
1325
- for row in self.data:
1326
- rows.append(", ".join(str(cell) for cell in row))
1327
-
1328
- # Check if it's a vector (single column)
1329
- is_vector = all(len(row) == 1 for row in self.data)
1330
-
1331
- if is_vector:
1332
- # Use vec() for vectors
1333
- matrix_content = ", ".join(str(row[0]) for row in self.data)
1334
- result = f"vec({matrix_content})"
1335
- else:
1336
- # Use mat() for matrices with semicolons separating rows
1337
- matrix_content = "; ".join(rows)
1338
- result = f"mat({matrix_content})"
1339
-
1340
- # Add bracket delimiters if needed
1341
- if self.bracket_type == "b": # square brackets
1342
- result = f"mat(delim: \"[\", {matrix_content})" if not is_vector else f"vec(delim: \"[\", {matrix_content})"
1343
- elif self.bracket_type == "v": # vertical bars (determinant)
1344
- result = f"mat(delim: \"|\", {matrix_content})"
1345
- elif self.bracket_type == "B": # curly braces
1346
- result = f"mat(delim: \"{{\", {matrix_content})"
1347
- # "p" (parentheses) is the default, no need to specify
1348
-
1349
- # Wrap in math mode
1350
- if self.inline:
1351
- return f"${result}$"
1352
- else:
1353
- return f"$ {result} $"
1354
-
1355
- class Picture(Leaf):
1356
- """
1357
- Image/diagram container with proper sizing and captioning.
1358
-
1359
- Handles image content with automatic upload management for Canvas
1360
- and proper LaTeX figure environments for PDF output.
1361
-
1362
- When to use:
1363
- - Diagrams, charts, or visual content
1364
- - Memory layout diagrams
1365
- - Process flowcharts
1366
- - Any visual aid for questions
1367
-
1368
- Features:
1369
- - Automatic Canvas image upload handling
1370
- - Proper LaTeX figure environments
1371
- - Responsive sizing with width control
1372
- - Optional captions
1373
-
1374
- Example:
1375
- # Image with caption
1376
- with open('diagram.png', 'rb') as f:
1377
- img_data = BytesIO(f.read())
1378
-
1379
- picture = ContentAST.Picture(
1380
- img_data=img_data,
1381
- caption="Memory layout diagram",
1382
- width="80%"
1383
- )
1384
- body.add_element(picture)
1234
+ Returns:
1235
+ str: LaTeX matrix string (e.g., "\\begin{bmatrix} 1 & 2 \\\\ 3 & 4 \\end{bmatrix}")
1385
1236
  """
1386
- def __init__(self, img_data, caption=None, width=None):
1387
- super().__init__("[picture]")
1388
- self.img_data = img_data
1389
- self.caption = caption
1390
- self.width = width
1391
- self.path = None # Will be set when image is saved
1392
-
1393
- def _ensure_image_saved(self):
1394
- """Save image data to file if not already saved."""
1395
- if self.path is None:
1396
-
1397
- # Create imgs directory if it doesn't exist (use absolute path)
1398
- img_dir = os.path.abspath("imgs")
1399
- if not os.path.exists(img_dir):
1400
- os.makedirs(img_dir)
1401
-
1402
- # Generate unique filename
1403
- filename = f"image-{uuid.uuid4()}.png"
1404
- self.path = os.path.join(img_dir, filename)
1405
-
1406
- # Save BytesIO data to file
1407
- with open(self.path, 'wb') as f:
1408
- self.img_data.seek(0) # Reset buffer position
1409
- f.write(self.img_data.read())
1410
-
1411
- def render_markdown(self, **kwargs):
1412
- self._ensure_image_saved()
1413
- if self.caption:
1414
- return f"![{self.caption}]({self.path})"
1415
- return f"![]({self.path})"
1416
-
1417
- def render_html(
1418
- self,
1419
- upload_func: Callable[[BytesIO], str] = lambda _: "",
1420
- **kwargs
1421
- ) -> str:
1422
- attrs = []
1423
- if self.width:
1424
- attrs.append(f'width="{self.width}"')
1425
-
1426
- img = f'<img src="{upload_func(self.img_data)}" {" ".join(attrs)} alt="{self.caption or ""}">'
1427
-
1428
- if self.caption:
1429
- return f'<figure>\n {img}\n <figcaption>{self.caption}</figcaption>\n</figure>'
1430
- return img
1237
+ rows = []
1238
+ for row in data:
1239
+ rows.append(" & ".join(str(cell) for cell in row))
1240
+ matrix_content = r" \\ ".join(rows)
1241
+ return f"\\begin{{{bracket_type}matrix}} {matrix_content} \\end{{{bracket_type}matrix}}"
1242
+
1243
+ def math_content_latex(self):
1244
+ """Return raw LaTeX math content without delimiters (for use in MathExpression)."""
1245
+ return Matrix.to_latex(self.data, self.bracket_type)
1246
+
1247
+ def math_content_typst(self):
1248
+ """Return raw Typst math content without delimiters (for use in MathExpression)."""
1249
+ # Build matrix content
1250
+ rows = []
1251
+ for row in self.data:
1252
+ rows.append(", ".join(str(cell) for cell in row))
1253
+
1254
+ # Check if it's a vector (single column)
1255
+ is_vector = all(len(row) == 1 for row in self.data)
1256
+
1257
+ if is_vector:
1258
+ # Use vec() for vectors
1259
+ matrix_content = ", ".join(str(row[0]) for row in self.data)
1260
+ result = f"vec({matrix_content})"
1261
+ else:
1262
+ # Use mat() for matrices with semicolons separating rows
1263
+ matrix_content = "; ".join(rows)
1264
+ result = f"mat({matrix_content})"
1265
+
1266
+ # Add bracket delimiters if needed
1267
+ if self.bracket_type == "b": # square brackets
1268
+ matrix_content_inner = ", ".join(str(row[0]) for row in self.data) if is_vector else "; ".join(rows)
1269
+ result = f"mat(delim: \"[\", {matrix_content_inner})" if not is_vector else f"vec(delim: \"[\", {matrix_content_inner})"
1270
+ elif self.bracket_type == "v": # vertical bars (determinant)
1271
+ result = f"mat(delim: \"|\", {'; '.join(rows)})"
1272
+ elif self.bracket_type == "V": # double vertical bars (norm)
1273
+ result = f"mat(delim: \"||\", {'; '.join(rows)})"
1274
+ elif self.bracket_type == "B": # curly braces
1275
+ result = f"mat(delim: \"{{\", {'; '.join(rows)})"
1276
+
1277
+ return result
1278
+
1279
+ def render_markdown(self, **kwargs):
1280
+ matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1281
+ rows = []
1282
+ for row in self.data:
1283
+ rows.append(" & ".join(str(cell) for cell in row))
1284
+ matrix_content = r" \\ ".join(rows)
1285
+
1286
+ if self.inline and self.bracket_type == "p":
1287
+ return f"$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$"
1288
+ else:
1289
+ return f"$\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}$"
1290
+
1291
+ def render_html(self, **kwargs):
1292
+ matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1293
+ rows = []
1294
+ if isinstance(self.data, np.ndarray):
1295
+ data = self.data.tolist()
1296
+ else:
1297
+ data = self.data
1298
+ for row in data:
1299
+ rows.append(" & ".join(str(cell) for cell in row))
1300
+ matrix_content = r" \\ ".join(rows)
1301
+
1302
+ if self.inline:
1303
+ return f"<span class='math'>$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$</span>"
1304
+ else:
1305
+ name_str = f"\\text{{{self.name}}} = " if self.name else ""
1306
+ return f"<div class='math'>$${name_str}\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}$$</div>"
1307
+
1308
+ def render_latex(self, **kwargs):
1309
+ matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1310
+ rows = []
1311
+ for row in self.data:
1312
+ rows.append(" & ".join(str(cell) for cell in row))
1313
+ matrix_content = r" \\ ".join(rows)
1314
+
1315
+ if self.inline and self.bracket_type == "p":
1316
+ return f"$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$"
1317
+ else:
1318
+ return f"\\[\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\]"
1319
+
1320
+ def render_typst(self, **kwargs):
1321
+ """Render matrix in Typst format using mat() and vec() functions."""
1322
+ # Build matrix content with semicolons separating rows
1323
+ rows = []
1324
+ for row in self.data:
1325
+ rows.append(", ".join(str(cell) for cell in row))
1326
+
1327
+ # Check if it's a vector (single column)
1328
+ is_vector = all(len(row) == 1 for row in self.data)
1329
+
1330
+ if is_vector:
1331
+ # Use vec() for vectors
1332
+ matrix_content = ", ".join(str(row[0]) for row in self.data)
1333
+ result = f"vec({matrix_content})"
1334
+ else:
1335
+ # Use mat() for matrices with semicolons separating rows
1336
+ matrix_content = "; ".join(rows)
1337
+ result = f"mat({matrix_content})"
1338
+
1339
+ # Add bracket delimiters if needed
1340
+ if self.bracket_type == "b": # square brackets
1341
+ result = f"mat(delim: \"[\", {matrix_content})" if not is_vector else f"vec(delim: \"[\", {matrix_content})"
1342
+ elif self.bracket_type == "v": # vertical bars (determinant)
1343
+ result = f"mat(delim: \"|\", {matrix_content})"
1344
+ elif self.bracket_type == "B": # curly braces
1345
+ result = f"mat(delim: \"{{\", {matrix_content})"
1346
+ # "p" (parentheses) is the default, no need to specify
1347
+
1348
+ # Wrap in math mode
1349
+ if self.inline:
1350
+ return f"${result}$"
1351
+ else:
1352
+ return f"$ {result} $"
1353
+
1354
+ class Picture(Leaf):
1355
+ """
1356
+ Image/diagram container with proper sizing and captioning.
1357
+
1358
+ Handles image content with automatic upload management for Canvas
1359
+ and proper LaTeX figure environments for PDF output.
1360
+
1361
+ When to use:
1362
+ - Diagrams, charts, or visual content
1363
+ - Memory layout diagrams
1364
+ - Process flowcharts
1365
+ - Any visual aid for questions
1366
+
1367
+ Features:
1368
+ - Automatic Canvas image upload handling
1369
+ - Proper LaTeX figure environments
1370
+ - Responsive sizing with width control
1371
+ - Optional captions
1372
+
1373
+ Example:
1374
+ # Image with caption
1375
+ with open('diagram.png', 'rb') as f:
1376
+ img_data = BytesIO(f.read())
1377
+
1378
+ picture = Picture(
1379
+ img_data=img_data,
1380
+ caption="Memory layout diagram",
1381
+ width="80%"
1382
+ )
1383
+ body.add_element(picture)
1384
+ """
1385
+ def __init__(self, img_data, caption=None, width=None):
1386
+ super().__init__("[picture]")
1387
+ self.img_data = img_data
1388
+ self.caption = caption
1389
+ self.width = width
1390
+ self.path = None # Will be set when image is saved
1391
+
1392
+ def _ensure_image_saved(self):
1393
+ """Save image data to file if not already saved."""
1394
+ if self.path is None:
1395
+
1396
+ # Create imgs directory if it doesn't exist (use absolute path)
1397
+ img_dir = os.path.abspath("imgs")
1398
+ if not os.path.exists(img_dir):
1399
+ os.makedirs(img_dir)
1400
+
1401
+ # Generate unique filename
1402
+ filename = f"image-{uuid.uuid4()}.png"
1403
+ self.path = os.path.join(img_dir, filename)
1404
+
1405
+ # Save BytesIO data to file
1406
+ with open(self.path, 'wb') as f:
1407
+ self.img_data.seek(0) # Reset buffer position
1408
+ f.write(self.img_data.read())
1409
+
1410
+ def render_markdown(self, **kwargs):
1411
+ self._ensure_image_saved()
1412
+ if self.caption:
1413
+ return f"![{self.caption}]({self.path})"
1414
+ return f"![]({self.path})"
1415
+
1416
+ def render_html(
1417
+ self,
1418
+ upload_func: Callable[[BytesIO], str] = lambda _: "",
1419
+ **kwargs
1420
+ ) -> str:
1421
+ attrs = []
1422
+ if self.width:
1423
+ attrs.append(f'width="{self.width}"')
1424
+
1425
+ img = f'<img src="{upload_func(self.img_data)}" {" ".join(attrs)} alt="{self.caption or ""}">'
1431
1426
 
1432
- def render_latex(self, **kwargs):
1433
- self._ensure_image_saved()
1427
+ if self.caption:
1428
+ return f'<figure>\n {img}\n <figcaption>{self.caption}</figcaption>\n</figure>'
1429
+ return img
1430
+
1431
+ def render_latex(self, **kwargs):
1432
+ self._ensure_image_saved()
1434
1433
 
1435
- options = []
1436
- if self.width:
1437
- options.append(f"width={self.width}")
1434
+ options = []
1435
+ if self.width:
1436
+ options.append(f"width={self.width}")
1438
1437
 
1439
- result = ["\\begin{figure}[h]"]
1440
- result.append(f"\\centering")
1441
- result.append(f"\\includegraphics[{','.join(options)}]{{{self.path}}}")
1438
+ result = ["\\begin{figure}[h]"]
1439
+ result.append(f"\\centering")
1440
+ result.append(f"\\includegraphics[{','.join(options)}]{{{self.path}}}")
1442
1441
 
1443
- if self.caption:
1444
- result.append(f"\\caption{{{self.caption}}}")
1442
+ if self.caption:
1443
+ result.append(f"\\caption{{{self.caption}}}")
1445
1444
 
1446
- result.append("\\end{figure}")
1447
- return "\n".join(result)
1445
+ result.append("\\end{figure}")
1446
+ return "\n".join(result)
1448
1447
 
1449
- def render_typst(self, **kwargs):
1450
- self._ensure_image_saved()
1448
+ def render_typst(self, **kwargs):
1449
+ self._ensure_image_saved()
1451
1450
 
1452
- # Build the image function call
1453
- img_params = []
1454
- if self.width:
1455
- img_params.append(f'width: {self.width}')
1451
+ # Build the image function call
1452
+ img_params = []
1453
+ if self.width:
1454
+ img_params.append(f'width: {self.width}')
1456
1455
 
1457
- params_str = ', '.join(img_params) if img_params else ''
1456
+ params_str = ', '.join(img_params) if img_params else ''
1458
1457
 
1459
- # Use Typst's figure and image functions
1460
- result = []
1461
- result.append("#figure(")
1462
- result.append(f' image("{self.path}"{", " + params_str if params_str else ""}),')
1458
+ # Use Typst's figure and image functions
1459
+ result = []
1460
+ result.append("#figure(")
1461
+ result.append(f' image("{self.path}"{", " + params_str if params_str else ""}),')
1463
1462
 
1464
- if self.caption:
1465
- result.append(f' caption: [{self.caption}]')
1463
+ if self.caption:
1464
+ result.append(f' caption: [{self.caption}]')
1466
1465
 
1467
- result.append(")")
1466
+ result.append(")")
1468
1467
 
1469
- return "\n".join(result)
1468
+ return "\n".join(result)
1470
1469
 
1471
- class LineBreak(Text):
1472
- def __init__(self, *args, **kwargs):
1473
- super().__init__("\n\n")
1470
+ class LineBreak(Text):
1471
+ def __init__(self, *args, **kwargs):
1472
+ super().__init__("\n\n")
1473
+
1474
+ ## Containers
1475
+
1476
+ class Paragraph(Container):
1477
+ """
1478
+ Text block container with proper spacing and paragraph formatting.
1479
+
1480
+ IMPORTANT: Use this for grouping text content, especially in question bodies.
1481
+ Automatically handles spacing between paragraphs and combines multiple
1482
+ lines/elements into a cohesive text block.
1483
+
1484
+ When to use:
1485
+ - Question instructions or problem statements
1486
+ - Multi-line text content
1487
+ - Grouping related text elements
1488
+ - Any text that should be visually separated as a paragraph
1489
+
1490
+ When NOT to use:
1491
+ - Single words or short phrases (use Text)
1492
+ - Mathematical content (use Equation)
1493
+ - Structured data (use Table)
1494
+
1495
+ Example:
1496
+ # Multi-line question text
1497
+ body.add_element(Paragraph([
1498
+ "Consider the following system:",
1499
+ "- Process A requires 4MB memory",
1500
+ "- Process B requires 2MB memory",
1501
+ "How much total memory is needed?"
1502
+ ]))
1503
+
1504
+ # Mixed content paragraph
1505
+ para = Paragraph([
1506
+ "The equation ",
1507
+ Equation("x^2 + 1 = 0", inline=True),
1508
+ " has no real solutions."
1509
+ ])
1510
+ """
1474
1511
 
1475
- ## Containers
1512
+ def __init__(self, lines_or_elements: List[str | Element] = None):
1513
+ super().__init__(add_spacing_before=True)
1514
+ for line in lines_or_elements:
1515
+ if isinstance(line, str):
1516
+ self.elements.append(Text(line))
1517
+ else:
1518
+ self.elements.append(line)
1476
1519
 
1477
- class Paragraph(Container):
1478
- """
1479
- Text block container with proper spacing and paragraph formatting.
1480
-
1481
- IMPORTANT: Use this for grouping text content, especially in question bodies.
1482
- Automatically handles spacing between paragraphs and combines multiple
1483
- lines/elements into a cohesive text block.
1484
-
1485
- When to use:
1486
- - Question instructions or problem statements
1487
- - Multi-line text content
1488
- - Grouping related text elements
1489
- - Any text that should be visually separated as a paragraph
1490
-
1491
- When NOT to use:
1492
- - Single words or short phrases (use ContentAST.Text)
1493
- - Mathematical content (use ContentAST.Equation)
1494
- - Structured data (use ContentAST.Table)
1495
-
1496
- Example:
1497
- # Multi-line question text
1498
- body.add_element(ContentAST.Paragraph([
1499
- "Consider the following system:",
1500
- "- Process A requires 4MB memory",
1501
- "- Process B requires 2MB memory",
1502
- "How much total memory is needed?"
1503
- ]))
1504
-
1505
- # Mixed content paragraph
1506
- para = ContentAST.Paragraph([
1507
- "The equation ",
1508
- ContentAST.Equation("x^2 + 1 = 0", inline=True),
1509
- " has no real solutions."
1510
- ])
1511
- """
1520
+ def render(self, output_format, **kwargs):
1521
+ # Add in new lines to break these up visually
1522
+ return "\n\n" + super().render(output_format, **kwargs) + "\n\n"
1523
+
1524
+ def render_html(self, **kwargs):
1525
+ return super().render_html(**kwargs) + "<br>"
1526
+
1527
+ def add_line(self, line: str):
1528
+ self.elements.append(Text(line))
1529
+
1530
+ class Table(Container):
1531
+ """
1532
+ Structured data table with cross-format rendering and proper formatting.
1533
+
1534
+ Creates properly formatted tables that work in PDF, Canvas, and Markdown.
1535
+ Automatically handles headers, alignment, and responsive formatting.
1536
+ All data is converted to content AST elements for consistent rendering.
1537
+
1538
+ When to use:
1539
+ - Structured data presentation (comparison tables, data sets)
1540
+ - Answer choices in tabular format
1541
+ - Organized information display
1542
+ - Memory layout diagrams, process tables, etc.
1543
+
1544
+ Features:
1545
+ - Automatic alignment control (left, right, center)
1546
+ - Optional headers with proper formatting
1547
+ - Canvas-compatible HTML output
1548
+ - LaTeX booktabs for professional PDF tables
1549
+
1550
+ Example:
1551
+ # Basic data table
1552
+ data = [
1553
+ ["Process A", "4MB", "Running"],
1554
+ ["Process B", "2MB", "Waiting"]
1555
+ ]
1556
+ headers = ["Process", "Memory", "Status"]
1557
+ table = Table(data=data, headers=headers, alignments=["left", "right", "center"])
1558
+ body.add_element(table)
1559
+
1560
+ # Mixed content table
1561
+ data = [
1562
+ [Text("x"), Equation("x^2", inline=True)],
1563
+ [Text("y"), Equation("y^2", inline=True)]
1564
+ ]
1565
+ """
1566
+
1567
+ def __init__(self, data, headers=None, alignments=None, padding=False, transpose=False, hide_rules=False):
1568
+ # todo: fix alignments
1569
+ # todo: implement transpose
1570
+ super().__init__()
1512
1571
 
1513
- def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
1514
- super().__init__(add_spacing_before=True)
1515
- for line in lines_or_elements:
1516
- if isinstance(line, str):
1517
- self.elements.append(ContentAST.Text(line))
1572
+ # Normalize data to content AST elements
1573
+ self.data = []
1574
+ for row in data:
1575
+ normalized_row = []
1576
+ for cell in row:
1577
+ if isinstance(cell, Element):
1578
+ normalized_row.append(cell)
1518
1579
  else:
1519
- self.elements.append(line)
1580
+ normalized_row.append(Text(str(cell)))
1581
+ self.data.append(normalized_row)
1520
1582
 
1521
- def render(self, output_format, **kwargs):
1522
- # Add in new lines to break these up visually
1523
- return "\n\n" + super().render(output_format, **kwargs) + "\n\n"
1524
-
1525
- def render_html(self, **kwargs):
1526
- return super().render_html(**kwargs) + "<br>"
1583
+ # Normalize headers to content AST elements
1584
+ if headers:
1585
+ self.headers = []
1586
+ for header in headers:
1587
+ if isinstance(header, Element):
1588
+ self.headers.append(header)
1589
+ else:
1590
+ self.headers.append(Text(str(header)))
1591
+ else:
1592
+ self.headers = None
1527
1593
 
1528
- def add_line(self, line: str):
1529
- self.elements.append(ContentAST.Text(line))
1594
+ self.alignments = alignments
1595
+ self.padding = padding,
1596
+ self.hide_rules = hide_rules
1530
1597
 
1531
- class Table(Container):
1532
- """
1533
- Structured data table with cross-format rendering and proper formatting.
1534
-
1535
- Creates properly formatted tables that work in PDF, Canvas, and Markdown.
1536
- Automatically handles headers, alignment, and responsive formatting.
1537
- All data is converted to ContentAST elements for consistent rendering.
1538
-
1539
- When to use:
1540
- - Structured data presentation (comparison tables, data sets)
1541
- - Answer choices in tabular format
1542
- - Organized information display
1543
- - Memory layout diagrams, process tables, etc.
1544
-
1545
- Features:
1546
- - Automatic alignment control (left, right, center)
1547
- - Optional headers with proper formatting
1548
- - Canvas-compatible HTML output
1549
- - LaTeX booktabs for professional PDF tables
1550
-
1551
- Example:
1552
- # Basic data table
1553
- data = [
1554
- ["Process A", "4MB", "Running"],
1555
- ["Process B", "2MB", "Waiting"]
1556
- ]
1557
- headers = ["Process", "Memory", "Status"]
1558
- table = ContentAST.Table(data=data, headers=headers, alignments=["left", "right", "center"])
1559
- body.add_element(table)
1560
-
1561
- # Mixed content table
1562
- data = [
1563
- [ContentAST.Text("x"), ContentAST.Equation("x^2", inline=True)],
1564
- [ContentAST.Text("y"), ContentAST.Equation("y^2", inline=True)]
1565
- ]
1566
- """
1598
+ def render_markdown(self, **kwargs):
1599
+ # Basic markdown table implementation
1600
+ result = []
1567
1601
 
1568
- def __init__(self, data, headers=None, alignments=None, padding=False, transpose=False, hide_rules=False):
1569
- # todo: fix alignments
1570
- # todo: implement transpose
1571
- super().__init__()
1572
-
1573
- # Normalize data to ContentAST elements
1574
- self.data = []
1575
- for row in data:
1576
- normalized_row = []
1577
- for cell in row:
1578
- if isinstance(cell, ContentAST.Element):
1579
- normalized_row.append(cell)
1580
- else:
1581
- normalized_row.append(ContentAST.Text(str(cell)))
1582
- self.data.append(normalized_row)
1602
+ if self.headers:
1603
+ result.append("| " + " | ".join(str(h) for h in self.headers) + " |")
1583
1604
 
1584
- # Normalize headers to ContentAST elements
1585
- if headers:
1586
- self.headers = []
1587
- for header in headers:
1588
- if isinstance(header, ContentAST.Element):
1589
- self.headers.append(header)
1590
- else:
1591
- self.headers.append(ContentAST.Text(str(header)))
1605
+ if self.alignments:
1606
+ align_row = []
1607
+ for align in self.alignments:
1608
+ if align == "left":
1609
+ align_row.append(":---")
1610
+ elif align == "right":
1611
+ align_row.append("---:")
1612
+ else: # center
1613
+ align_row.append(":---:")
1614
+ result.append("| " + " | ".join(align_row) + " |")
1592
1615
  else:
1593
- self.headers = None
1594
-
1595
- self.alignments = alignments
1596
- self.padding = padding,
1597
- self.hide_rules = hide_rules
1616
+ result.append("| " + " | ".join(["---"] * len(self.headers)) + " |")
1598
1617
 
1599
- def render_markdown(self, **kwargs):
1600
- # Basic markdown table implementation
1601
- result = []
1602
-
1603
- if self.headers:
1604
- result.append("| " + " | ".join(str(h) for h in self.headers) + " |")
1605
-
1606
- if self.alignments:
1607
- align_row = []
1608
- for align in self.alignments:
1609
- if align == "left":
1610
- align_row.append(":---")
1611
- elif align == "right":
1612
- align_row.append("---:")
1613
- else: # center
1614
- align_row.append(":---:")
1615
- result.append("| " + " | ".join(align_row) + " |")
1616
- else:
1617
- result.append("| " + " | ".join(["---"] * len(self.headers)) + " |")
1618
-
1619
- for row in self.data:
1620
- result.append("| " + " | ".join(str(cell) for cell in row) + " |")
1621
-
1622
- return "\n".join(result)
1618
+ for row in self.data:
1619
+ result.append("| " + " | ".join(str(cell) for cell in row) + " |")
1623
1620
 
1624
- def render_html(self, **kwargs):
1625
- # HTML table implementation
1626
- result = ["<table border=\"1\" style=\"border-collapse: collapse; width: 100%;\">"]
1627
-
1628
- result.append(" <tbody>")
1629
-
1630
- # Render headers as bold first row instead of <th> tags for Canvas compatibility
1631
- if self.headers:
1632
- result.append(" <tr>")
1633
- for i, header in enumerate(self.headers):
1634
- align_attr = ""
1635
- if self.alignments and i < len(self.alignments):
1636
- align_attr = f' align="{self.alignments[i]}"'
1637
- # Render header as bold content in regular <td> tag
1638
- rendered_header = header.render(output_format="html", **kwargs)
1639
- result.append(
1640
- f" <td style=\"padding: {'5px' if self.padding else '0x'}; font-weight: bold; {align_attr};\"><b>{rendered_header}</b></td>"
1641
- )
1642
- result.append(" </tr>")
1643
-
1644
- # Render data rows
1645
- for row in self.data:
1646
- result.append(" <tr>")
1647
- for i, cell in enumerate(row):
1648
- if isinstance(cell, ContentAST.Element):
1649
- cell = cell.render(output_format="html", **kwargs)
1650
- align_attr = ""
1651
- if self.alignments and i < len(self.alignments):
1652
- align_attr = f' align="{self.alignments[i]}"'
1653
- result.append(f" <td style=\"padding: {'5px' if self.padding else '0x'} ; {align_attr};\">{cell}</td>")
1654
- result.append(" </tr>")
1655
- result.append(" </tbody>")
1656
- result.append("</table>")
1657
-
1658
- return "\n".join(result)
1621
+ return "\n".join(result)
1622
+
1623
+ def render_html(self, **kwargs):
1624
+ # HTML table implementation
1625
+ result = ["<table border=\"1\" style=\"border-collapse: collapse; width: 100%;\">"]
1659
1626
 
1660
- def render_latex(self, **kwargs):
1661
- # LaTeX table implementation
1662
- if self.alignments:
1663
- col_spec = "".join(
1664
- "l" if a == "left" else "r" if a == "right" else "c"
1665
- for a in self.alignments
1627
+ result.append(" <tbody>")
1628
+
1629
+ # Render headers as bold first row instead of <th> tags for Canvas compatibility
1630
+ if self.headers:
1631
+ result.append(" <tr>")
1632
+ for i, header in enumerate(self.headers):
1633
+ align_attr = ""
1634
+ if self.alignments and i < len(self.alignments):
1635
+ align_attr = f' align="{self.alignments[i]}"'
1636
+ # Render header as bold content in regular <td> tag
1637
+ rendered_header = header.render(output_format="html", **kwargs)
1638
+ result.append(
1639
+ f" <td style=\"padding: {'5px' if self.padding else '0x'}; font-weight: bold; {align_attr};\"><b>{rendered_header}</b></td>"
1666
1640
  )
1667
- else:
1668
- col_spec = '|'.join(["l"] * (len(self.headers) if self.headers else len(self.data[0])))
1669
-
1670
- result = [f"\\begin{{tabular}}{{{col_spec}}}"]
1671
- if not self.hide_rules: result.append("\\toprule")
1672
-
1673
- if self.headers:
1674
- # Now all headers are ContentAST elements, so render them consistently
1675
- rendered_headers = [header.render(output_format="latex", **kwargs) for header in self.headers]
1676
- result.append(" & ".join(rendered_headers) + " \\\\")
1677
- if not self.hide_rules: result.append("\\midrule")
1678
-
1679
- for row in self.data:
1680
- # All data cells are now ContentAST elements, so render them consistently
1681
- rendered_row = [cell.render(output_format="latex", **kwargs) for cell in row]
1682
- result.append(" & ".join(rendered_row) + " \\\\")
1683
-
1684
- if len(self.data) > 1 and not self.hide_rules:
1685
- result.append("\\bottomrule")
1686
- result.append("\\end{tabular}")
1687
-
1688
- return "\n\n" + "\n".join(result)
1641
+ result.append(" </tr>")
1689
1642
 
1690
- def render_typst(self, **kwargs):
1691
- """
1692
- Render table in Typst format using native table() function.
1693
-
1694
- Typst syntax:
1695
- #table(
1696
- columns: N,
1697
- align: (left, center, right),
1698
- [Header1], [Header2],
1699
- [Cell1], [Cell2]
1643
+ # Render data rows
1644
+ for row in self.data:
1645
+ result.append(" <tr>")
1646
+ for i, cell in enumerate(row):
1647
+ if isinstance(cell, Element):
1648
+ cell = cell.render(output_format="html", **kwargs)
1649
+ align_attr = ""
1650
+ if self.alignments and i < len(self.alignments):
1651
+ align_attr = f' align="{self.alignments[i]}"'
1652
+ result.append(f" <td style=\"padding: {'5px' if self.padding else '0x'} ; {align_attr};\">{cell}</td>")
1653
+ result.append(" </tr>")
1654
+ result.append(" </tbody>")
1655
+ result.append("</table>")
1656
+
1657
+ return "\n".join(result)
1658
+
1659
+ def render_latex(self, **kwargs):
1660
+ # LaTeX table implementation
1661
+ if self.alignments:
1662
+ col_spec = "".join(
1663
+ "l" if a == "left" else "r" if a == "right" else "c"
1664
+ for a in self.alignments
1700
1665
  )
1701
- """
1702
- # Determine number of columns
1703
- num_cols = len(self.headers) if self.headers else len(self.data[0])
1704
-
1705
- # Build alignment specification
1706
- if self.alignments:
1707
- # Map alignment strings to Typst alignment
1708
- align_map = {"left": "left", "right": "right", "center": "center"}
1709
- aligns = [align_map.get(a, "left") for a in self.alignments]
1710
- align_spec = f"align: ({', '.join(aligns)})"
1711
- else:
1712
- align_spec = "align: left"
1713
-
1714
- # Start table
1715
- result = [f"table("]
1716
- result.append(f" columns: {num_cols},")
1717
- result.append(f" {align_spec},")
1718
-
1719
- # Add stroke if not hiding rules
1720
- if not self.hide_rules:
1721
- result.append(f" stroke: 0.5pt,")
1722
- else:
1723
- result.append(f" stroke: none,")
1724
-
1725
- # Collect all rows (headers + data) and calculate column widths for alignment
1726
- all_rows = []
1727
-
1728
- # Render headers
1729
- if self.headers:
1730
- header_cells = []
1731
- for header in self.headers:
1732
- rendered = header.render(output_format="typst", **kwargs).strip()
1733
- header_cells.append(f"[*{rendered}*]")
1734
- all_rows.append(header_cells)
1735
-
1736
- # Render data rows
1737
- for row in self.data:
1738
- row_cells = []
1739
- for cell in row:
1740
- rendered = cell.render(output_format="typst", **kwargs).strip()
1741
- row_cells.append(f"[{rendered}]")
1742
- all_rows.append(row_cells)
1743
-
1744
- # Calculate max width for each column
1745
- col_widths = [0] * num_cols
1746
- for row in all_rows:
1747
- for i, cell in enumerate(row):
1748
- col_widths[i] = max(col_widths[i], len(cell))
1749
-
1750
- # Format rows with padding
1751
- for row in all_rows:
1752
- padded_cells = []
1753
- for i, cell in enumerate(row):
1754
- padded_cells.append(cell.ljust(col_widths[i]))
1755
- result.append(f" {', '.join(padded_cells)},")
1756
-
1757
- result.append(")")
1758
-
1759
- return "\n#box(" + "\n".join(result) + "\n)"
1666
+ else:
1667
+ col_spec = '|'.join(["l"] * (len(self.headers) if self.headers else len(self.data[0])))
1668
+
1669
+ result = [f"\\begin{{tabular}}{{{col_spec}}}"]
1670
+ if not self.hide_rules: result.append("\\toprule")
1671
+
1672
+ if self.headers:
1673
+ # Now all headers are content AST elements, so render them consistently
1674
+ rendered_headers = [header.render(output_format="latex", **kwargs) for header in self.headers]
1675
+ result.append(" & ".join(rendered_headers) + " \\\\")
1676
+ if not self.hide_rules: result.append("\\midrule")
1677
+
1678
+ for row in self.data:
1679
+ # All data cells are now content AST elements, so render them consistently
1680
+ rendered_row = [cell.render(output_format="latex", **kwargs) for cell in row]
1681
+ result.append(" & ".join(rendered_row) + " \\\\")
1682
+
1683
+ if len(self.data) > 1 and not self.hide_rules:
1684
+ result.append("\\bottomrule")
1685
+ result.append("\\end{tabular}")
1686
+
1687
+ return "\n\n" + "\n".join(result)
1760
1688
 
1761
- class TableGroup(Container):
1689
+ def render_typst(self, **kwargs):
1762
1690
  """
1763
- Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
1764
-
1765
- Use this when you need to show multiple related tables together, such as
1766
- multiple page tables in hierarchical paging questions. In LaTeX, tables
1767
- are displayed side-by-side using minipages. In HTML/Canvas, they're stacked
1768
- vertically for better mobile compatibility.
1769
-
1770
- When to use:
1771
- - Multiple related tables that should be visually grouped
1772
- - Page tables in hierarchical paging
1773
- - Comparison of multiple data structures
1774
-
1775
- Features:
1776
- - Automatic side-by-side layout in PDF (using minipages)
1777
- - Vertical stacking in HTML for better readability
1778
- - Automatic width calculation based on number of tables
1779
- - Optional labels for each table
1780
-
1781
- Example:
1782
- # Create table group with labels
1783
- table_group = ContentAST.TableGroup()
1784
-
1785
- table_group.add_table(
1786
- label="Page Table #0",
1787
- table=ContentAST.Table(headers=["PTI", "PTE"], data=pt0_data)
1788
- )
1691
+ Render table in Typst format using native table() function.
1692
+
1693
+ Typst syntax:
1694
+ #table(
1695
+ columns: N,
1696
+ align: (left, center, right),
1697
+ [Header1], [Header2],
1698
+ [Cell1], [Cell2]
1699
+ )
1700
+ """
1701
+ # Determine number of columns
1702
+ num_cols = len(self.headers) if self.headers else len(self.data[0])
1703
+
1704
+ # Build alignment specification
1705
+ if self.alignments:
1706
+ # Map alignment strings to Typst alignment
1707
+ align_map = {"left": "left", "right": "right", "center": "center"}
1708
+ aligns = [align_map.get(a, "left") for a in self.alignments]
1709
+ align_spec = f"align: ({', '.join(aligns)})"
1710
+ else:
1711
+ align_spec = "align: left"
1712
+
1713
+ # Start table
1714
+ result = [f"table("]
1715
+ result.append(f" columns: {num_cols},")
1716
+ result.append(f" {align_spec},")
1717
+
1718
+ # Add stroke if not hiding rules
1719
+ if not self.hide_rules:
1720
+ result.append(f" stroke: 0.5pt,")
1721
+ else:
1722
+ result.append(f" stroke: none,")
1723
+
1724
+ # Collect all rows (headers + data) and calculate column widths for alignment
1725
+ all_rows = []
1726
+
1727
+ # Render headers
1728
+ if self.headers:
1729
+ header_cells = []
1730
+ for header in self.headers:
1731
+ rendered = header.render(output_format="typst", **kwargs).strip()
1732
+ header_cells.append(f"[*{rendered}*]")
1733
+ all_rows.append(header_cells)
1734
+
1735
+ # Render data rows
1736
+ for row in self.data:
1737
+ row_cells = []
1738
+ for cell in row:
1739
+ rendered = cell.render(output_format="typst", **kwargs).strip()
1740
+ row_cells.append(f"[{rendered}]")
1741
+ all_rows.append(row_cells)
1742
+
1743
+ # Calculate max width for each column
1744
+ col_widths = [0] * num_cols
1745
+ for row in all_rows:
1746
+ for i, cell in enumerate(row):
1747
+ col_widths[i] = max(col_widths[i], len(cell))
1748
+
1749
+ # Format rows with padding
1750
+ for row in all_rows:
1751
+ padded_cells = []
1752
+ for i, cell in enumerate(row):
1753
+ padded_cells.append(cell.ljust(col_widths[i]))
1754
+ result.append(f" {', '.join(padded_cells)},")
1755
+
1756
+ result.append(")")
1757
+
1758
+ return "\n#box(" + "\n".join(result) + "\n)"
1789
1759
 
1790
- table_group.add_table(
1791
- label="Page Table #1",
1792
- table=ContentAST.Table(headers=["PTI", "PTE"], data=pt1_data)
1793
- )
1760
+ class TableGroup(Container):
1761
+ """
1762
+ Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
1763
+
1764
+ Use this when you need to show multiple related tables together, such as
1765
+ multiple page tables in hierarchical paging questions. In LaTeX, tables
1766
+ are displayed side-by-side using minipages. In HTML/Canvas, they're stacked
1767
+ vertically for better mobile compatibility.
1768
+
1769
+ When to use:
1770
+ - Multiple related tables that should be visually grouped
1771
+ - Page tables in hierarchical paging
1772
+ - Comparison of multiple data structures
1773
+
1774
+ Features:
1775
+ - Automatic side-by-side layout in PDF (using minipages)
1776
+ - Vertical stacking in HTML for better readability
1777
+ - Automatic width calculation based on number of tables
1778
+ - Optional labels for each table
1779
+
1780
+ Example:
1781
+ # Create table group with labels
1782
+ table_group = TableGroup()
1783
+
1784
+ table_group.add_table(
1785
+ label="Page Table #0",
1786
+ table=Table(headers=["PTI", "PTE"], data=pt0_data)
1787
+ )
1794
1788
 
1795
- body.add_element(table_group)
1796
- """
1797
- def __init__(self):
1798
- super().__init__()
1799
- self.tables = [] # List of (label, table) tuples
1789
+ table_group.add_table(
1790
+ label="Page Table #1",
1791
+ table=Table(headers=["PTI", "PTE"], data=pt1_data)
1792
+ )
1800
1793
 
1801
- def add_table(self, table: ContentAST.Table, label: str = None):
1802
- """
1803
- Add a table to the group with an optional label.
1794
+ body.add_element(table_group)
1795
+ """
1796
+ def __init__(self):
1797
+ super().__init__()
1798
+ self.tables = [] # List of (label, table) tuples
1804
1799
 
1805
- Args:
1806
- table: ContentAST.Table to add
1807
- label: Optional label to display above the table
1808
- """
1809
- self.tables.append((label, table))
1810
-
1811
- def render_html(self, **kwargs):
1812
- # Stack tables vertically in HTML
1813
- result = []
1814
- for label, table in self.tables:
1815
- if label:
1816
- result.append(f"<p><b>{label}</b></p>")
1817
- result.append(table.render("html", **kwargs))
1818
- return "\n".join(result)
1819
-
1820
- def render_latex(self, **kwargs):
1821
- if not self.tables:
1822
- return ""
1823
-
1824
- # Calculate width based on number of tables
1825
- num_tables = len(self.tables)
1826
- if num_tables == 1:
1827
- width = 0.9
1828
- elif num_tables == 2:
1829
- width = 0.45
1830
- else: # 3 or more
1831
- width = 0.30
1832
-
1833
- result = ["\n\n"] # Add spacing before table group
1834
-
1835
- for i, (label, table) in enumerate(self.tables):
1836
- result.append(f"\\begin{{minipage}}{{{width}\\textwidth}}")
1837
-
1838
- if label:
1839
- # Escape # characters in labels for LaTeX
1840
- escaped_label = label.replace("#", r"\#")
1841
- result.append(f"\\textbf{{{escaped_label}}}")
1842
- result.append("\\vspace{0.1cm}")
1843
-
1844
- # Render the table
1845
- table_latex = table.render("latex", **kwargs)
1846
- result.append(table_latex)
1847
-
1848
- result.append("\\end{minipage}")
1849
-
1850
- # Add horizontal spacing between tables (but not after the last one)
1851
- if i < num_tables - 1:
1852
- result.append("\\hfill")
1853
-
1854
- return "\n".join(result)
1855
-
1856
- def render_typst(self, **kwargs):
1857
- """
1858
- Render table group in Typst format using grid layout for side-by-side tables.
1800
+ def add_table(self, table: Table, label: str = None):
1801
+ """
1802
+ Add a table to the group with an optional label.
1859
1803
 
1860
- Uses Typst's grid() function to arrange tables horizontally with automatic
1861
- column sizing and spacing.
1862
- """
1863
- if not self.tables:
1864
- return ""
1804
+ Args:
1805
+ table: Table to add
1806
+ label: Optional label to display above the table
1807
+ """
1808
+ self.tables.append((label, table))
1809
+
1810
+ def render_html(self, **kwargs):
1811
+ # Stack tables vertically in HTML
1812
+ result = []
1813
+ for label, table in self.tables:
1814
+ if label:
1815
+ result.append(f"<p><b>{label}</b></p>")
1816
+ result.append(table.render("html", **kwargs))
1817
+ return "\n".join(result)
1818
+
1819
+ def render_latex(self, **kwargs):
1820
+ if not self.tables:
1821
+ return ""
1822
+
1823
+ # Calculate width based on number of tables
1824
+ num_tables = len(self.tables)
1825
+ if num_tables == 1:
1826
+ width = 0.9
1827
+ elif num_tables == 2:
1828
+ width = 0.45
1829
+ else: # 3 or more
1830
+ width = 0.30
1831
+
1832
+ result = ["\n\n"] # Add spacing before table group
1833
+
1834
+ for i, (label, table) in enumerate(self.tables):
1835
+ result.append(f"\\begin{{minipage}}{{{width}\\textwidth}}")
1836
+
1837
+ if label:
1838
+ # Escape # characters in labels for LaTeX
1839
+ escaped_label = label.replace("#", r"\#")
1840
+ result.append(f"\\textbf{{{escaped_label}}}")
1841
+ result.append("\\vspace{0.1cm}")
1842
+
1843
+ # Render the table
1844
+ table_latex = table.render("latex", **kwargs)
1845
+ result.append(table_latex)
1846
+
1847
+ result.append("\\end{minipage}")
1848
+
1849
+ # Add horizontal spacing between tables (but not after the last one)
1850
+ if i < num_tables - 1:
1851
+ result.append("\\hfill")
1852
+
1853
+ return "\n".join(result)
1854
+
1855
+ def render_typst(self, **kwargs):
1856
+ """
1857
+ Render table group in Typst format using grid layout for side-by-side tables.
1858
+
1859
+ Uses Typst's grid() function to arrange tables horizontally with automatic
1860
+ column sizing and spacing.
1861
+ """
1862
+ if not self.tables:
1863
+ return ""
1865
1864
 
1866
- num_tables = len(self.tables)
1865
+ num_tables = len(self.tables)
1867
1866
 
1868
- # Start grid with equal-width columns and some spacing
1869
- result = ["\n#grid("]
1870
- result.append(f" columns: {num_tables},")
1871
- result.append(f" column-gutter: 1em,")
1872
- result.append(f" row-gutter: 0.5em,")
1867
+ # Start grid with equal-width columns and some spacing
1868
+ result = ["\n#grid("]
1869
+ result.append(f" columns: {num_tables},")
1870
+ result.append(f" column-gutter: 1em,")
1871
+ result.append(f" row-gutter: 0.5em,")
1873
1872
 
1874
- # Add each table as a grid cell
1875
- for label, table in self.tables:
1876
- result.append(" [") # Start grid cell
1873
+ # Add each table as a grid cell
1874
+ for label, table in self.tables:
1875
+ result.append(" [") # Start grid cell
1877
1876
 
1878
- if label:
1879
- # Escape # characters in labels (already done by Text.render_typst)
1880
- result.append(f" *{label}*")
1881
- result.append(" #v(0.1cm)")
1882
- result.append("") # Empty line for spacing
1877
+ if label:
1878
+ # Escape # characters in labels (already done by Text.render_typst)
1879
+ result.append(f" *{label}*")
1880
+ result.append(" #v(0.1cm)")
1881
+ result.append("") # Empty line for spacing
1883
1882
 
1884
- # Render the table (indent for readability)
1885
- table_typst = table.render("typst", **kwargs)
1886
- # Indent each line of the table
1887
- indented_table = "\n".join(f" {line}" if line else "" for line in table_typst.split("\n"))
1888
- result.append(indented_table)
1883
+ # Render the table (indent for readability)
1884
+ table_typst = table.render("typst", **kwargs)
1885
+ # Indent each line of the table
1886
+ indented_table = "\n".join(f" {line}" if line else "" for line in table_typst.split("\n"))
1887
+ result.append(indented_table)
1889
1888
 
1890
- result.append(" ],") # End grid cell
1889
+ result.append(" ],") # End grid cell
1891
1890
 
1892
- result.append(")")
1893
- result.append("") # Empty line after grid
1891
+ result.append(")")
1892
+ result.append("") # Empty line after grid
1894
1893
 
1895
- return "\n".join(result)
1894
+ return "\n".join(result)
1896
1895
 
1897
- class AnswerBlock(Table):
1898
- """
1899
- Specialized table for organizing multiple answer fields with proper spacing.
1900
-
1901
- Creates a clean layout for multiple answer inputs with extra vertical
1902
- spacing in PDF output. Inherits from Table but optimized for answers.
1903
-
1904
- When to use:
1905
- - Questions with multiple answer fields
1906
- - Organized answer input sections
1907
- - Better visual grouping of related answers
1908
-
1909
- Example:
1910
- # Multiple related answers - Answer extends Leaf, use factory methods
1911
- memory_ans = ContentAST.Answer.integer("memory", self.memory_value, label="Memory used", unit="MB")
1912
- time_ans = ContentAST.Answer.auto_float("time", self.time_value, label="Execution time", unit="ms")
1913
- answer_block = ContentAST.AnswerBlock([memory_ans, time_ans])
1914
- body.add_element(answer_block)
1915
-
1916
- # Single answer with better spacing
1917
- result_ans = ContentAST.Answer.integer("result", self.result_value, label="Final result")
1918
- single_answer = ContentAST.AnswerBlock(result_ans)
1919
- """
1920
- def __init__(self, answers: ContentAST.Answer|List[ContentAST.Answer]):
1921
- if not isinstance(answers, list):
1922
- answers = [answers]
1923
-
1924
- super().__init__(
1925
- data=[
1926
- [answer]
1927
- for answer in answers
1928
- ]
1929
- )
1930
- self.hide_rules = True
1931
-
1932
- def add_element(self, element):
1933
- self.data.append(element)
1934
-
1935
- def render_latex(self, **kwargs):
1936
- rendered_content = super().render_latex(**kwargs)
1937
- content = (
1938
- r"{"
1939
- r"\setlength{\extrarowheight}{20pt}"
1940
- + rendered_content +
1941
- r"}"
1942
- )
1943
- return content
1896
+ class AnswerBlock(Table):
1897
+ """
1898
+ Specialized table for organizing multiple answer fields with proper spacing.
1899
+
1900
+ Creates a clean layout for multiple answer inputs with extra vertical
1901
+ spacing in PDF output. Inherits from Table but optimized for answers.
1902
+
1903
+ When to use:
1904
+ - Questions with multiple answer fields
1905
+ - Organized answer input sections
1906
+ - Better visual grouping of related answers
1907
+
1908
+ Example:
1909
+ # Multiple related answers - Answer extends Leaf, use factory methods
1910
+ memory_ans = Answer.integer("memory", self.memory_value, label="Memory used", unit="MB")
1911
+ time_ans = Answer.auto_float("time", self.time_value, label="Execution time", unit="ms")
1912
+ answer_block = AnswerBlock([memory_ans, time_ans])
1913
+ body.add_element(answer_block)
1914
+
1915
+ # Single answer with better spacing
1916
+ result_ans = Answer.integer("result", self.result_value, label="Final result")
1917
+ single_answer = AnswerBlock(result_ans)
1918
+ """
1919
+ def __init__(self, answers: Answer|List[Answer]):
1920
+ if not isinstance(answers, list):
1921
+ answers = [answers]
1922
+
1923
+ super().__init__(
1924
+ data=[
1925
+ [answer]
1926
+ for answer in answers
1927
+ ]
1928
+ )
1929
+ self.hide_rules = True
1930
+
1931
+ def add_element(self, element):
1932
+ self.data.append(element)
1933
+
1934
+ def render_latex(self, **kwargs):
1935
+ rendered_content = super().render_latex(**kwargs)
1936
+ content = (
1937
+ r"{"
1938
+ r"\setlength{\extrarowheight}{20pt}"
1939
+ + rendered_content +
1940
+ r"}"
1941
+ )
1942
+ return content
1944
1943
 
1945
- ## Specialized Elements
1946
- class RepeatedProblemPart(Container):
1944
+ ## Specialized Elements
1945
+ class RepeatedProblemPart(Container):
1946
+ """
1947
+ Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
1948
+
1949
+ Creates the specialized alignat* LaTeX format for multipart math problems
1950
+ where each subpart is labeled and aligned properly. Used primarily for
1951
+ vector math questions that need multiple similar calculations.
1952
+
1953
+ When to use:
1954
+ - Questions with multiple subparts that need (a), (b), (c) labeling
1955
+ - Vector math problems with repeated calculations
1956
+ - Any math problem where subparts should be aligned
1957
+
1958
+ Features:
1959
+ - Automatic subpart labeling with (a), (b), (c), etc.
1960
+ - Proper LaTeX alignat* formatting for PDF
1961
+ - HTML fallback with organized layout
1962
+ - Flexible content support (equations, matrices, etc.)
1963
+
1964
+ Example:
1965
+ # Create subparts for vector dot products
1966
+ subparts = [
1967
+ (Matrix([[1], [2]]), "\\cdot", Matrix([[3], [4]])),
1968
+ (Matrix([[5], [6]]), "\\cdot", Matrix([[7], [8]]))
1969
+ ]
1970
+ repeated_part = RepeatedProblemPart(subparts)
1971
+ body.add_element(repeated_part)
1972
+ """
1973
+ def __init__(self, subpart_contents):
1947
1974
  """
1948
- Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
1949
-
1950
- Creates the specialized alignat* LaTeX format for multipart math problems
1951
- where each subpart is labeled and aligned properly. Used primarily for
1952
- vector math questions that need multiple similar calculations.
1953
-
1954
- When to use:
1955
- - Questions with multiple subparts that need (a), (b), (c) labeling
1956
- - Vector math problems with repeated calculations
1957
- - Any math problem where subparts should be aligned
1958
-
1959
- Features:
1960
- - Automatic subpart labeling with (a), (b), (c), etc.
1961
- - Proper LaTeX alignat* formatting for PDF
1962
- - HTML fallback with organized layout
1963
- - Flexible content support (equations, matrices, etc.)
1964
-
1965
- Example:
1966
- # Create subparts for vector dot products
1967
- subparts = [
1968
- (ContentAST.Matrix([[1], [2]]), "\\cdot", ContentAST.Matrix([[3], [4]])),
1969
- (ContentAST.Matrix([[5], [6]]), "\\cdot", ContentAST.Matrix([[7], [8]]))
1970
- ]
1971
- repeated_part = ContentAST.RepeatedProblemPart(subparts)
1972
- body.add_element(repeated_part)
1975
+ Create a repeated problem part with multiple subquestions.
1976
+
1977
+ Args:
1978
+ subpart_contents: List of content for each subpart.
1979
+ Each item can be:
1980
+ - A string (rendered as equation)
1981
+ - A Element
1982
+ - A tuple/list of elements to be joined
1973
1983
  """
1974
- def __init__(self, subpart_contents):
1975
- """
1976
- Create a repeated problem part with multiple subquestions.
1977
-
1978
- Args:
1979
- subpart_contents: List of content for each subpart.
1980
- Each item can be:
1981
- - A string (rendered as equation)
1982
- - A ContentAST.Element
1983
- - A tuple/list of elements to be joined
1984
- """
1985
- super().__init__()
1986
- self.subpart_contents = subpart_contents
1987
-
1988
- def render_markdown(self, **kwargs):
1989
- result = []
1990
- for i, content in enumerate(self.subpart_contents):
1991
- letter = chr(ord('a') + i) # Convert to (a), (b), (c), etc.
1992
- if isinstance(content, str):
1993
- result.append(f"({letter}) {content}")
1994
- elif isinstance(content, (list, tuple)):
1995
- content_str = " ".join(str(item) for item in content)
1996
- result.append(f"({letter}) {content_str}")
1997
- else:
1998
- result.append(f"({letter}) {str(content)}")
1999
- return "\n\n".join(result)
2000
-
2001
- def render_html(self, **kwargs):
2002
- result = []
2003
- for i, content in enumerate(self.subpart_contents):
2004
- letter = chr(ord('a') + i)
2005
- if isinstance(content, str):
2006
- result.append(f"<p>({letter}) {content}</p>")
2007
- elif isinstance(content, (list, tuple)):
2008
- rendered_items = []
2009
- for item in content:
2010
- if hasattr(item, 'render'):
2011
- rendered_items.append(item.render('html', **kwargs))
2012
- else:
2013
- rendered_items.append(str(item))
2014
- content_str = " ".join(rendered_items)
2015
- result.append(f"<p>({letter}) {content_str}</p>")
2016
- else:
2017
- if hasattr(content, 'render'):
2018
- content_str = content.render('html', **kwargs)
1984
+ super().__init__()
1985
+ self.subpart_contents = subpart_contents
1986
+
1987
+ def render_markdown(self, **kwargs):
1988
+ result = []
1989
+ for i, content in enumerate(self.subpart_contents):
1990
+ letter = chr(ord('a') + i) # Convert to (a), (b), (c), etc.
1991
+ if isinstance(content, str):
1992
+ result.append(f"({letter}) {content}")
1993
+ elif isinstance(content, (list, tuple)):
1994
+ content_str = " ".join(str(item) for item in content)
1995
+ result.append(f"({letter}) {content_str}")
1996
+ else:
1997
+ result.append(f"({letter}) {str(content)}")
1998
+ return "\n\n".join(result)
1999
+
2000
+ def render_html(self, **kwargs):
2001
+ result = []
2002
+ for i, content in enumerate(self.subpart_contents):
2003
+ letter = chr(ord('a') + i)
2004
+ if isinstance(content, str):
2005
+ result.append(f"<p>({letter}) {content}</p>")
2006
+ elif isinstance(content, (list, tuple)):
2007
+ rendered_items = []
2008
+ for item in content:
2009
+ if hasattr(item, 'render'):
2010
+ rendered_items.append(item.render('html', **kwargs))
2019
2011
  else:
2020
- content_str = str(content)
2021
- result.append(f"<p>({letter}) {content_str}</p>")
2022
- return "\n".join(result)
2023
-
2024
- def render_latex(self, **kwargs):
2025
- if not self.subpart_contents:
2026
- return ""
2027
-
2028
- # Start alignat environment - use 2 columns for alignment
2029
- result = [r"\begin{alignat*}{2}"]
2030
-
2031
- for i, content in enumerate(self.subpart_contents):
2032
- letter = chr(ord('a') + i)
2033
- spacing = r"\\[6pt]" if i < len(self.subpart_contents) - 1 else r" \\"
2034
-
2035
- if isinstance(content, str):
2036
- # Treat as raw LaTeX equation content
2037
- result.append(f"({letter})\\;& {content} &=&\\; {spacing}")
2038
- elif isinstance(content, (list, tuple)):
2039
- # Join multiple elements (e.g., matrix, operator, matrix)
2040
- rendered_items = []
2041
- for item in content:
2042
- if hasattr(item, 'render'):
2043
- rendered_items.append(item.render('latex', **kwargs))
2044
- elif isinstance(item, str):
2045
- rendered_items.append(item)
2046
- else:
2047
- rendered_items.append(str(item))
2048
- content_str = " ".join(rendered_items)
2049
- result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
2012
+ rendered_items.append(str(item))
2013
+ content_str = " ".join(rendered_items)
2014
+ result.append(f"<p>({letter}) {content_str}</p>")
2015
+ else:
2016
+ if hasattr(content, 'render'):
2017
+ content_str = content.render('html', **kwargs)
2050
2018
  else:
2051
- # Single element (ContentAST element or string)
2052
- if hasattr(content, 'render'):
2053
- content_str = content.render('latex', **kwargs)
2019
+ content_str = str(content)
2020
+ result.append(f"<p>({letter}) {content_str}</p>")
2021
+ return "\n".join(result)
2022
+
2023
+ def render_latex(self, **kwargs):
2024
+ if not self.subpart_contents:
2025
+ return ""
2026
+
2027
+ # Start alignat environment - use 2 columns for alignment
2028
+ result = [r"\begin{alignat*}{2}"]
2029
+
2030
+ for i, content in enumerate(self.subpart_contents):
2031
+ letter = chr(ord('a') + i)
2032
+ spacing = r"\\[6pt]" if i < len(self.subpart_contents) - 1 else r" \\"
2033
+
2034
+ if isinstance(content, str):
2035
+ # Treat as raw LaTeX equation content
2036
+ result.append(f"({letter})\\;& {content} &=&\\; {spacing}")
2037
+ elif isinstance(content, (list, tuple)):
2038
+ # Join multiple elements (e.g., matrix, operator, matrix)
2039
+ rendered_items = []
2040
+ for item in content:
2041
+ if hasattr(item, 'render'):
2042
+ rendered_items.append(item.render('latex', **kwargs))
2043
+ elif isinstance(item, str):
2044
+ rendered_items.append(item)
2054
2045
  else:
2055
- content_str = str(content)
2056
- result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
2046
+ rendered_items.append(str(item))
2047
+ content_str = " ".join(rendered_items)
2048
+ result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
2049
+ else:
2050
+ # Single element (content AST element or string)
2051
+ if hasattr(content, 'render'):
2052
+ content_str = content.render('latex', **kwargs)
2053
+ else:
2054
+ content_str = str(content)
2055
+ result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
2057
2056
 
2058
- result.append(r"\end{alignat*}")
2059
- return "\n".join(result)
2057
+ result.append(r"\end{alignat*}")
2058
+ return "\n".join(result)
2060
2059
 
2061
- class OnlyLatex(Container):
2062
- """
2063
- Container element that only renders content in LaTeX/PDF output format.
2060
+ class OnlyLatex(Container):
2061
+ """
2062
+ Container element that only renders content in LaTeX/PDF output format.
2064
2063
 
2065
- Use this when you need LaTeX-specific content that should not appear
2066
- in HTML/Canvas or Markdown outputs. Content is completely hidden
2067
- from non-LaTeX formats.
2064
+ Use this when you need LaTeX-specific content that should not appear
2065
+ in HTML/Canvas or Markdown outputs. Content is completely hidden
2066
+ from non-LaTeX formats.
2068
2067
 
2069
- When to use:
2070
- - LaTeX-specific formatting that has no HTML equivalent
2071
- - PDF-only instructions or content
2072
- - Complex LaTeX commands that break HTML rendering
2068
+ When to use:
2069
+ - LaTeX-specific formatting that has no HTML equivalent
2070
+ - PDF-only instructions or content
2071
+ - Complex LaTeX commands that break HTML rendering
2073
2072
 
2074
- Example:
2075
- # LaTeX-only spacing or formatting
2076
- latex_only = ContentAST.OnlyLatex()
2077
- latex_only.add_element(ContentAST.Text("\\newpage"))
2073
+ Example:
2074
+ # LaTeX-only spacing or formatting
2075
+ latex_only = OnlyLatex()
2076
+ latex_only.add_element(Text("\\newpage"))
2078
2077
 
2079
- # Add to main content - only appears in PDF
2080
- body.add_element(latex_only)
2081
- """
2078
+ # Add to main content - only appears in PDF
2079
+ body.add_element(latex_only)
2080
+ """
2081
+
2082
+ def render(self, output_format: OutputFormat, **kwargs):
2083
+ if output_format not in ("latex", "typst"):
2084
+ return ""
2085
+ return super().render(output_format=output_format, **kwargs)
2086
+
2087
+ class OnlyHtml(Container):
2088
+ """
2089
+ Container element that only renders content in HTML/Canvas output format.
2090
+
2091
+ Use this when you need HTML-specific content that should not appear
2092
+ in LaTeX/PDF or Markdown outputs. Content is completely hidden
2093
+ from non-HTML formats.
2082
2094
 
2083
- def render(self, output_format: ContentAST.OutputFormat, **kwargs):
2084
- if output_format not in ("latex", "typst"):
2085
- return ""
2086
- return super().render(output_format=output_format, **kwargs)
2095
+ When to use:
2096
+ - Canvas-specific instructions or formatting
2097
+ - HTML-only interactive elements
2098
+ - Content that doesn't translate well to PDF
2099
+
2100
+ Example:
2101
+ # HTML-only instructions
2102
+ html_only = OnlyHtml()
2103
+ html_only.add_element(Text("Click submit when done"))
2104
+
2105
+ # Add to main content - only appears in Canvas
2106
+ body.add_element(html_only)
2107
+ """
2087
2108
 
2088
- class OnlyHtml(Container):
2089
- """
2090
- Container element that only renders content in HTML/Canvas output format.
2109
+ def render(self, output_format, **kwargs):
2110
+ if output_format != "html":
2111
+ return ""
2112
+ return super().render(output_format, **kwargs)
2091
2113
 
2092
- Use this when you need HTML-specific content that should not appear
2093
- in LaTeX/PDF or Markdown outputs. Content is completely hidden
2094
- from non-HTML formats.
2114
+ class Answer(Leaf):
2115
+ """
2116
+ Unified answer class combining data storage, Canvas export, and rendering.
2095
2117
 
2096
- When to use:
2097
- - Canvas-specific instructions or formatting
2098
- - HTML-only interactive elements
2099
- - Content that doesn't translate well to PDF
2118
+ Extends Leaf to integrate seamlessly with the content AST tree while
2119
+ maintaining all Canvas export functionality.
2100
2120
 
2101
- Example:
2102
- # HTML-only instructions
2103
- html_only = ContentAST.OnlyHtml()
2104
- html_only.add_element(ContentAST.Text("Click submit when done"))
2121
+ CRITICAL: Use this for ALL answer inputs in questions.
2122
+ Creates appropriate input fields that work across both PDF and Canvas formats.
2123
+ In PDF, renders as blank lines for students to fill in.
2124
+ In HTML/Canvas, can display the answer for checking.
2105
2125
 
2106
- # Add to main content - only appears in Canvas
2107
- body.add_element(html_only)
2108
- """
2126
+ Example:
2127
+ # Basic answer field
2128
+ ans = Answer.integer("result", 42, label="Result", unit="MB")
2129
+ body.add_element(ans)
2130
+ answers.append(ans) # Track for Canvas export
2131
+ """
2132
+
2133
+ DEFAULT_ROUNDING_DIGITS = 4
2134
+
2135
+ class CanvasAnswerKind(enum.Enum):
2136
+ BLANK = "fill_in_multiple_blanks_question"
2137
+ MULTIPLE_ANSWER = "multiple_answers_question"
2138
+ ESSAY = "essay_question"
2139
+ MULTIPLE_DROPDOWN = "multiple_dropdowns_question"
2140
+ NUMERICAL_QUESTION = "numerical_question"
2141
+
2142
+ class VariableKind(enum.Enum):
2143
+ STR = enum.auto()
2144
+ INT = enum.auto()
2145
+ FLOAT = enum.auto()
2146
+ BINARY = enum.auto()
2147
+ HEX = enum.auto()
2148
+ BINARY_OR_HEX = enum.auto()
2149
+ AUTOFLOAT = enum.auto()
2150
+ LIST = enum.auto()
2151
+ VECTOR = enum.auto()
2152
+ MATRIX = enum.auto()
2153
+
2154
+ def __init__(
2155
+ self,
2156
+ value=None,
2157
+ kind: Answer.CanvasAnswerKind = None,
2158
+ variable_kind: Answer.VariableKind = None,
2159
+ *,
2160
+ # Data fields (from misc.Answer)
2161
+ display=None,
2162
+ length=None,
2163
+ correct=True,
2164
+ baffles=None,
2165
+ pdf_only=False,
2166
+ # Rendering fields (from Answer)
2167
+ label: str = "",
2168
+ unit: str = "",
2169
+ blank_length=5,
2170
+ ):
2109
2171
 
2110
- def render(self, output_format, **kwargs):
2111
- if output_format != "html":
2112
- return ""
2113
- return super().render(output_format, **kwargs)
2114
-
2115
- class Answer(Leaf):
2116
- """
2117
- Unified answer class combining data storage, Canvas export, and rendering.
2172
+ # Initialize Leaf with label as content
2173
+ super().__init__(content=label if label else "")
2174
+
2175
+ # Data fields
2176
+ self.key = str(uuid.uuid4())
2177
+ self.value = value
2178
+ self.kind = kind if kind is not None else Answer.CanvasAnswerKind.BLANK
2179
+ self.variable_kind = variable_kind if variable_kind is not None else Answer.VariableKind.STR
2180
+
2181
+ # For list values in display, show the first option (or join them with /)
2182
+ if display is not None:
2183
+ self.display = display
2184
+ elif isinstance(value, list) and isinstance(self.variable_kind, AnswerTypes.String):
2185
+ self.display = value[0] if len(value) == 1 else " / ".join(value)
2186
+ else:
2187
+ self.display = value
2188
+
2189
+ self.length = length # Used for bits and hex to be printed appropriately
2190
+ self.correct = correct
2191
+ self.baffles = baffles
2192
+ self.pdf_only = pdf_only
2193
+
2194
+ # Rendering fields
2195
+ self.label = label
2196
+ self.unit = unit
2197
+ self.blank_length = blank_length
2198
+
2199
+ # Canvas export methods (from misc.Answer)
2200
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2201
+ """Generate Canvas answer dictionaries based on variable_kind."""
2202
+
2203
+ # If this answer is PDF-only, don't send it to Canvas
2204
+ if self.pdf_only:
2205
+ return []
2118
2206
 
2119
- Extends ContentAST.Leaf to integrate seamlessly with the ContentAST tree while
2120
- maintaining all Canvas export functionality.
2207
+ canvas_answers = []
2208
+ if isinstance(self.value, list):
2209
+ canvas_answers = [
2210
+ {
2211
+ "blank_id": self.key,
2212
+ "answer_text": str(alt),
2213
+ "answer_weight": 100 if self.correct else 0,
2214
+ }
2215
+ for alt in self.value
2216
+ ]
2217
+ else:
2218
+ canvas_answers = [{
2219
+ "blank_id": self.key,
2220
+ "answer_text": self.value,
2221
+ "answer_weight": 100 if self.correct else 0,
2222
+ }]
2223
+
2224
+ # Add baffles (incorrect answer choices)
2225
+ if self.baffles is not None:
2226
+ for baffle in self.baffles:
2227
+ canvas_answers.append({
2228
+ "blank_id": self.key,
2229
+ "answer_text": baffle,
2230
+ "answer_weight": 0,
2231
+ })
2121
2232
 
2122
- CRITICAL: Use this for ALL answer inputs in questions.
2123
- Creates appropriate input fields that work across both PDF and Canvas formats.
2124
- In PDF, renders as blank lines for students to fill in.
2125
- In HTML/Canvas, can display the answer for checking.
2233
+ return canvas_answers
2126
2234
 
2127
- Example:
2128
- # Basic answer field
2129
- ans = ContentAST.Answer.integer("result", 42, label="Result", unit="MB")
2130
- body.add_element(ans)
2131
- answers.append(ans) # Track for Canvas export
2132
- """
2235
+ def get_display_string(self) -> str:
2236
+ """Get the formatted display string for this answer (for grading/answer keys)."""
2237
+ return str(self.display if hasattr(self, 'display') else self.value)
2133
2238
 
2134
- DEFAULT_ROUNDING_DIGITS = 4
2135
-
2136
- class CanvasAnswerKind(enum.Enum):
2137
- BLANK = "fill_in_multiple_blanks_question"
2138
- MULTIPLE_ANSWER = "multiple_answers_question"
2139
- ESSAY = "essay_question"
2140
- MULTIPLE_DROPDOWN = "multiple_dropdowns_question"
2141
- NUMERICAL_QUESTION = "numerical_question"
2142
-
2143
- class VariableKind(enum.Enum):
2144
- STR = enum.auto()
2145
- INT = enum.auto()
2146
- FLOAT = enum.auto()
2147
- BINARY = enum.auto()
2148
- HEX = enum.auto()
2149
- BINARY_OR_HEX = enum.auto()
2150
- AUTOFLOAT = enum.auto()
2151
- LIST = enum.auto()
2152
- VECTOR = enum.auto()
2153
- MATRIX = enum.auto()
2154
-
2155
- def __init__(
2156
- self,
2157
- value=None,
2158
- kind: ContentAST.Answer.CanvasAnswerKind = None,
2159
- variable_kind: ContentAST.Answer.VariableKind = None,
2160
- *,
2161
- # Data fields (from misc.Answer)
2162
- display=None,
2163
- length=None,
2164
- correct=True,
2165
- baffles=None,
2166
- pdf_only=False,
2167
- # Rendering fields (from ContentAST.Answer)
2168
- label: str = "",
2169
- unit: str = "",
2170
- blank_length=5,
2171
- ):
2172
-
2173
- # Initialize Leaf with label as content
2174
- super().__init__(content=label if label else "")
2175
-
2176
- # Data fields
2177
- self.key = str(uuid.uuid4())
2178
- self.value = value
2179
- self.kind = kind if kind is not None else ContentAST.Answer.CanvasAnswerKind.BLANK
2180
- self.variable_kind = variable_kind if variable_kind is not None else ContentAST.Answer.VariableKind.STR
2181
-
2182
- # For list values in display, show the first option (or join them with /)
2183
- if display is not None:
2184
- self.display = display
2185
- elif isinstance(value, list) and isinstance(self.variable_kind, AnswerTypes.String):
2186
- self.display = value[0] if len(value) == 1 else " / ".join(value)
2187
- else:
2188
- self.display = value
2239
+ # Rendering methods (override Leaf's defaults)
2240
+ def render_markdown(self, **kwargs):
2241
+ return f"{self.label + (':' if len(self.label) > 0 else '')} [{self.key}] {self.unit}".strip()
2189
2242
 
2190
- self.length = length # Used for bits and hex to be printed appropriately
2191
- self.correct = correct
2192
- self.baffles = baffles
2193
- self.pdf_only = pdf_only
2243
+ def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
2244
+ if can_be_numerical:
2245
+ return f"Calculate {self.label}"
2246
+ if show_answers:
2247
+ answer_display = self.get_display_string()
2248
+ label_part = f"{self.label}:" if self.label else ""
2249
+ unit_part = f" {self.unit}" if self.unit else ""
2250
+ return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
2251
+ else:
2252
+ return self.render_markdown(**kwargs)
2194
2253
 
2195
- # Rendering fields
2196
- self.label = label
2197
- self.unit = unit
2198
- self.blank_length = blank_length
2254
+ def render_latex(self, **kwargs):
2255
+ return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.blank_length}}} {self.unit}".strip()
2199
2256
 
2200
- # Canvas export methods (from misc.Answer)
2201
- def get_for_canvas(self, single_answer=False) -> List[dict]:
2202
- """Generate Canvas answer dictionaries based on variable_kind."""
2203
-
2204
- # If this answer is PDF-only, don't send it to Canvas
2205
- if self.pdf_only:
2206
- return []
2257
+ def render_typst(self, **kwargs):
2258
+ """Render answer blank as an underlined space in Typst."""
2259
+ blank_width = self.blank_length * 0.75 # Convert character length to cm
2260
+ blank = f"#fillline(width: {blank_width}cm)"
2207
2261
 
2208
- canvas_answers = []
2209
- if isinstance(self.value, list):
2210
- canvas_answers = [
2211
- {
2212
- "blank_id": self.key,
2213
- "answer_text": str(alt),
2214
- "answer_weight": 100 if self.correct else 0,
2215
- }
2216
- for alt in self.value
2217
- ]
2218
- else:
2219
- canvas_answers = [{
2220
- "blank_id": self.key,
2221
- "answer_text": self.value,
2222
- "answer_weight": 100 if self.correct else 0,
2223
- }]
2262
+ label_part = f"{self.label}:" if self.label else ""
2263
+ unit_part = f" {self.unit}" if self.unit else ""
2224
2264
 
2225
- # Add baffles (incorrect answer choices)
2226
- if self.baffles is not None:
2227
- for baffle in self.baffles:
2228
- canvas_answers.append({
2229
- "blank_id": self.key,
2230
- "answer_text": baffle,
2231
- "answer_weight": 0,
2232
- })
2265
+ return f"{label_part} {blank}{unit_part}".strip()
2266
+
2267
+ # Factory methods for common answer types
2268
+ @classmethod
2269
+ def dropdown(cls, value: str, baffles: list = None, **kwargs) -> 'Answer':
2270
+ """Create a dropdown answer with wrong answer choices (baffles)"""
2271
+ return cls(
2272
+ value=value,
2273
+ kind=cls.CanvasAnswerKind.MULTIPLE_DROPDOWN,
2274
+ baffles=baffles,
2275
+ **kwargs
2276
+ )
2233
2277
 
2234
- return canvas_answers
2278
+ @classmethod
2279
+ def multiple_choice(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'Answer':
2280
+ """Create a multiple choice answer with wrong answer choices (baffles)"""
2281
+ return cls(
2282
+ value=value,
2283
+ kind=cls.CanvasAnswerKind.MULTIPLE_ANSWER,
2284
+ baffles=baffles,
2285
+ **kwargs
2286
+ )
2235
2287
 
2236
- def get_display_string(self) -> str:
2237
- """Get the formatted display string for this answer (for grading/answer keys)."""
2238
- return str(self.display if hasattr(self, 'display') else self.value)
2239
-
2240
- # Rendering methods (override Leaf's defaults)
2241
- def render_markdown(self, **kwargs):
2242
- return f"{self.label + (':' if len(self.label) > 0 else '')} [{self.key}] {self.unit}".strip()
2243
-
2244
- def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
2245
- if can_be_numerical:
2246
- return f"Calculate {self.label}"
2247
- if show_answers:
2248
- answer_display = self.get_display_string()
2249
- label_part = f"{self.label}:" if self.label else ""
2250
- unit_part = f" {self.unit}" if self.unit else ""
2251
- return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
2288
+ # Static helper methods
2289
+ @staticmethod
2290
+ def _to_fraction(x):
2291
+ """Convert int/float/decimal.Decimal/fractions.Fraction/str to fractions.Fraction exactly."""
2292
+ if isinstance(x, fractions.Fraction):
2293
+ return x
2294
+ if isinstance(x, int):
2295
+ return fractions.Fraction(x, 1)
2296
+ if isinstance(x, decimal.Decimal):
2297
+ # exact conversion of decimal.Decimal to fractions.Fraction
2298
+ sign, digits, exp = x.as_tuple()
2299
+ n = 0
2300
+ for d in digits:
2301
+ n = n * 10 + d
2302
+ n = -n if sign else n
2303
+ if exp >= 0:
2304
+ return fractions.Fraction(n * (10 ** exp), 1)
2252
2305
  else:
2253
- return self.render_markdown(**kwargs)
2306
+ return fractions.Fraction(n, 10 ** (-exp))
2307
+ if isinstance(x, str):
2308
+ s = x.strip()
2309
+ if '/' in s:
2310
+ a, b = s.split('/', 1)
2311
+ return fractions.Fraction(int(a.strip()), int(b.strip()))
2312
+ return fractions.Fraction(decimal.Decimal(s))
2313
+ # float or other numerics
2314
+ return fractions.Fraction(decimal.Decimal(str(x)))
2315
+
2316
+ @staticmethod
2317
+ def accepted_strings(value, max_denominator=720):
2318
+ """
2319
+ Return a sorted list of acceptable answer strings for Canvas.
2320
+
2321
+ Generates:
2322
+ - Integer form (if value is a whole number)
2323
+ - Fixed decimal with DEFAULT_ROUNDING_DIGITS (e.g., "1.0000")
2324
+ - Trimmed decimal without trailing zeros (e.g., "1.25")
2325
+ - Simple fraction if exactly representable (e.g., "5/4")
2326
+
2327
+ Examples:
2328
+ 1 → ["1", "1.0000"]
2329
+ 1.25 → ["1.25", "1.2500", "5/4"]
2330
+ 0.123444... → ["0.1234"]
2331
+ """
2332
+ rounding_digits = Answer.DEFAULT_ROUNDING_DIGITS
2333
+ decimal.getcontext().prec = max(34, rounding_digits + 10)
2254
2334
 
2255
- def render_latex(self, **kwargs):
2256
- return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.blank_length}}} {self.unit}".strip()
2335
+ outs = set()
2257
2336
 
2258
- def render_typst(self, **kwargs):
2259
- """Render answer blank as an underlined space in Typst."""
2260
- blank_width = self.blank_length * 0.75 # Convert character length to cm
2261
- blank = f"#fillline(width: {blank_width}cm)"
2337
+ # Round to our standard precision first
2338
+ q = decimal.Decimal(1).scaleb(-rounding_digits)
2339
+ rounded_decimal = decimal.Decimal(str(value)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2262
2340
 
2263
- label_part = f"{self.label}:" if self.label else ""
2264
- unit_part = f" {self.unit}" if self.unit else ""
2341
+ # Normalize negative zero to positive zero
2342
+ if rounded_decimal == 0:
2343
+ rounded_decimal = abs(rounded_decimal)
2265
2344
 
2266
- return f"{label_part} {blank}{unit_part}".strip()
2267
-
2268
- # Factory methods for common answer types
2269
- @classmethod
2270
- def dropdown(cls, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
2271
- """Create a dropdown answer with wrong answer choices (baffles)"""
2272
- return cls(
2273
- value=value,
2274
- kind=cls.CanvasAnswerKind.MULTIPLE_DROPDOWN,
2275
- baffles=baffles,
2276
- **kwargs
2277
- )
2345
+ # Fixed decimal form (e.g., "1.2500")
2346
+ fixed_str = format(rounded_decimal, 'f')
2347
+ outs.add(fixed_str)
2278
2348
 
2279
- @classmethod
2280
- def multiple_choice(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
2281
- """Create a multiple choice answer with wrong answer choices (baffles)"""
2282
- return cls(
2283
- value=value,
2284
- kind=cls.CanvasAnswerKind.MULTIPLE_ANSWER,
2285
- baffles=baffles,
2286
- **kwargs
2287
- )
2349
+ # Trimmed decimal form (e.g., "1.25")
2350
+ trimmed_str = fixed_str.rstrip('0').rstrip('.')
2351
+ if trimmed_str.startswith('.'):
2352
+ trimmed_str = '0' + trimmed_str
2353
+ outs.add(trimmed_str)
2288
2354
 
2289
- # Static helper methods
2290
- @staticmethod
2291
- def _to_fraction(x):
2292
- """Convert int/float/decimal.Decimal/fractions.Fraction/str to fractions.Fraction exactly."""
2293
- if isinstance(x, fractions.Fraction):
2294
- return x
2295
- if isinstance(x, int):
2296
- return fractions.Fraction(x, 1)
2297
- if isinstance(x, decimal.Decimal):
2298
- # exact conversion of decimal.Decimal to fractions.Fraction
2299
- sign, digits, exp = x.as_tuple()
2300
- n = 0
2301
- for d in digits:
2302
- n = n * 10 + d
2303
- n = -n if sign else n
2304
- if exp >= 0:
2305
- return fractions.Fraction(n * (10 ** exp), 1)
2306
- else:
2307
- return fractions.Fraction(n, 10 ** (-exp))
2308
- if isinstance(x, str):
2309
- s = x.strip()
2310
- if '/' in s:
2311
- a, b = s.split('/', 1)
2312
- return fractions.Fraction(int(a.strip()), int(b.strip()))
2313
- return fractions.Fraction(decimal.Decimal(s))
2314
- # float or other numerics
2315
- return fractions.Fraction(decimal.Decimal(str(x)))
2316
-
2317
- @staticmethod
2318
- def accepted_strings(
2319
- value,
2320
- *,
2321
- allow_integer=True,
2322
- allow_simple_fraction=True,
2323
- max_denominator=720,
2324
- allow_mixed=False,
2325
- include_spaces=False,
2326
- include_fixed_even_if_integer=False
2327
- ):
2328
- """Return a sorted list of strings you can paste into Canvas as alternate correct answers."""
2329
- decimal.getcontext().prec = max(34, (ContentAST.Answer.DEFAULT_ROUNDING_DIGITS or 0) + 10)
2330
- f = ContentAST.Answer._to_fraction(value)
2331
- outs = set()
2332
-
2333
- # Integer form
2334
- if f.denominator == 1 and allow_integer:
2335
- outs.add(str(f.numerator))
2336
- if include_fixed_even_if_integer:
2337
- q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2338
- d = decimal.Decimal(f.numerator).quantize(q, rounding=decimal.ROUND_HALF_UP)
2339
- outs.add(format(d, 'f'))
2340
-
2341
- # Simple fraction
2342
- if allow_simple_fraction:
2343
- fr = f.limit_denominator(max_denominator)
2344
- if fr == f:
2345
- a, b = fr.numerator, fr.denominator
2346
- if fr.denominator > 1:
2347
- outs.add(f"{a}/{b}")
2348
- if include_spaces:
2349
- outs.add(f"{a} / {b}")
2350
- if allow_mixed and b != 1 and abs(a) > b:
2351
- sign = '-' if a < 0 else ''
2352
- A = abs(a)
2353
- whole, rem = divmod(A, b)
2354
- outs.add(f"{sign}{whole} {rem}/{b}")
2355
- else:
2356
- return sorted(outs, key=lambda s: (len(s), s))
2357
-
2358
- # Fixed-decimal form
2359
- q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2360
- d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2361
- outs.add(format(d, 'f'))
2362
-
2363
- # Trimmed decimal
2364
- if ContentAST.Answer.DEFAULT_ROUNDING_DIGITS:
2365
- q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2366
- d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2367
- s = format(d, 'f').rstrip('0').rstrip('.')
2368
- if s.startswith('.'):
2369
- s = '0' + s
2370
- if s == '-0':
2371
- s = '0'
2372
- outs.add(s)
2373
-
2374
- return sorted(outs, key=lambda s: (len(s), s))
2375
-
2376
- @staticmethod
2377
- def fix_negative_zero(x):
2378
- """Fix -0.0 display issue."""
2379
- return 0.0 if x == 0 else x
2380
-
2381
- @staticmethod
2382
- def get_spacing_variations_of_list(l, remove_space=False):
2383
- return [', '.join(l)] + ([', '.join(l)] if remove_space else [])
2384
-
2355
+ # Integer form (if it's a whole number after rounding)
2356
+ if rounded_decimal == rounded_decimal.to_integral_value():
2357
+ outs.add(str(int(rounded_decimal)))
2358
+
2359
+ # Fraction form (only if exactly representable with reasonable denominator)
2360
+ f = Answer._to_fraction(rounded_decimal)
2361
+ fr = f.limit_denominator(max_denominator)
2362
+ if fr == f and fr.denominator > 1:
2363
+ outs.add(f"{fr.numerator}/{fr.denominator}")
2364
+
2365
+ return sorted(outs, key=lambda s: (len(s), s))
2366
+
2367
+ @staticmethod
2368
+ def fix_negative_zero(x):
2369
+ """Fix -0.0 display issue."""
2370
+ return 0.0 if x == 0 else x
2371
+
2372
+ @staticmethod
2373
+ def get_spacing_variations_of_list(l, remove_space=False):
2374
+ return [', '.join(l)] + ([', '.join(l)] if remove_space else [])
2375
+
2385
2376
 
2386
2377
  class AnswerTypes:
2387
2378
  # Multibase answers that can accept either hex, binary or decimal
2388
- class MultiBase(ContentAST.Answer):
2379
+ class MultiBase(Answer):
2389
2380
  """
2390
2381
  These are answers that can accept answers in any sort of format, and default to displaying in hex when written out.
2391
2382
  This will be the parent class for Binary, Hex, and Integer answers most likely.
@@ -2419,50 +2410,45 @@ class AnswerTypes:
2419
2410
  "answer_weight": 100 if self.correct else 0,
2420
2411
  },
2421
2412
  ]
2422
-
2413
+
2423
2414
  return canvas_answers
2424
2415
 
2425
2416
  def get_display_string(self) -> str:
2426
2417
  # This is going to be the default for multi-base answers, but may change later.
2427
2418
  hex_digits = (self.length // 4) + 1 if self.length is not None else 0
2428
2419
  return f"0x{self.value:0{hex_digits}X}"
2429
-
2420
+
2430
2421
  class Hex(MultiBase):
2431
2422
  pass
2432
-
2423
+
2433
2424
  class Binary(MultiBase):
2434
2425
  def get_display_string(self) -> str:
2435
2426
  return f"0b{self.value:0{self.length if self.length is not None else 0}b}"
2436
-
2427
+
2437
2428
  class Decimal(MultiBase):
2438
2429
  def get_display_string(self) -> str:
2439
2430
  return f"{self.value:0{self.length if self.length is not None else 0}}"
2440
2431
 
2441
2432
  # Concrete type answers
2442
- class Float(ContentAST.Answer):
2433
+ class Float(Answer):
2443
2434
  def get_for_canvas(self, single_answer=False) -> List[dict]:
2444
2435
  if single_answer:
2445
2436
  canvas_answers = [
2446
2437
  {
2447
2438
  "numerical_answer_type": "exact_answer",
2448
- "answer_text": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
2449
- "answer_exact": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
2439
+ "answer_text": round(self.value, Answer.DEFAULT_ROUNDING_DIGITS),
2440
+ "answer_exact": round(self.value, Answer.DEFAULT_ROUNDING_DIGITS),
2450
2441
  "answer_error_margin": 0.1,
2451
2442
  "answer_weight": 100 if self.correct else 0,
2452
2443
  }
2453
2444
  ]
2454
2445
  else:
2455
2446
  # Use the accepted_strings helper
2456
- answer_strings = ContentAST.Answer.accepted_strings(
2447
+ answer_strings = Answer.accepted_strings(
2457
2448
  self.value,
2458
- allow_integer=True,
2459
- allow_simple_fraction=True,
2460
- max_denominator=60,
2461
- allow_mixed=True,
2462
- include_spaces=False,
2463
- include_fixed_even_if_integer=True
2449
+ max_denominator=60
2464
2450
  )
2465
-
2451
+
2466
2452
  canvas_answers = [
2467
2453
  {
2468
2454
  "blank_id": self.key,
@@ -2472,14 +2458,14 @@ class AnswerTypes:
2472
2458
  for answer_string in answer_strings
2473
2459
  ]
2474
2460
  return canvas_answers
2475
-
2461
+
2476
2462
  def get_display_string(self) -> str:
2477
- rounded = round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2463
+ rounded = round(self.value, Answer.DEFAULT_ROUNDING_DIGITS)
2478
2464
  return f"{self.fix_negative_zero(rounded)}"
2479
-
2480
- class Int(ContentAST.Answer):
2481
2465
 
2482
- # Canvas export methods (from misc.Answer)
2466
+ class Int(Answer):
2467
+
2468
+ # Canvas export methods (from misc.Answer)
2483
2469
  def get_for_canvas(self, single_answer=False) -> List[dict]:
2484
2470
  canvas_answers = [
2485
2471
  {
@@ -2491,19 +2477,19 @@ class AnswerTypes:
2491
2477
  return canvas_answers
2492
2478
 
2493
2479
  # Open Ended
2494
- class OpenEnded(ContentAST.Answer):
2480
+ class OpenEnded(Answer):
2495
2481
  def __init__(self, *args, **kwargs):
2496
2482
  super().__init__(*args, **kwargs)
2497
- self.kind=ContentAST.Answer.CanvasAnswerKind.ESSAY
2498
-
2499
- class String(ContentAST.Answer):
2483
+ self.kind=Answer.CanvasAnswerKind.ESSAY
2484
+
2485
+ class String(Answer):
2500
2486
  pass
2501
-
2502
- class List(ContentAST.Answer):
2487
+
2488
+ class List(Answer):
2503
2489
  def __init__(self, order_matters=True, *args, **kwargs):
2504
2490
  super().__init__(*args, **kwargs)
2505
2491
  self.order_matters = order_matters
2506
-
2492
+
2507
2493
  def get_for_canvas(self, single_answer=False):
2508
2494
  if self.order_matters:
2509
2495
  canvas_answers = [
@@ -2525,7 +2511,7 @@ class AnswerTypes:
2525
2511
  for possible_state in itertools.permutations(self.value)
2526
2512
  for spacing_variation in self.get_spacing_variations_of_list(possible_state)
2527
2513
  ]
2528
-
2514
+
2529
2515
  return canvas_answers
2530
2516
 
2531
2517
  def get_display_string(self) -> str:
@@ -2533,19 +2519,19 @@ class AnswerTypes:
2533
2519
  return ", ".join(str(v) for v in self.value)
2534
2520
 
2535
2521
  # Math types
2536
- class Vector(ContentAST.Answer):
2522
+ class Vector(Answer):
2537
2523
  """
2538
2524
  These are self-contained vectors that will go in a single answer block
2539
2525
  """
2540
-
2541
- # Canvas export methods (from misc.Answer)
2526
+
2527
+ # Canvas export methods (from misc.Answer)
2542
2528
  def get_for_canvas(self, single_answer=False) -> List[dict]:
2543
2529
  # Get all answer variations
2544
2530
  answer_variations = [
2545
- ContentAST.Answer.accepted_strings(dimension_value)
2531
+ Answer.accepted_strings(dimension_value)
2546
2532
  for dimension_value in self.value
2547
2533
  ]
2548
-
2534
+
2549
2535
  canvas_answers = []
2550
2536
  for combination in itertools.product(*answer_variations):
2551
2537
  for spacing_variation in self.get_spacing_variations_of_list(list(combination)):
@@ -2562,27 +2548,27 @@ class AnswerTypes:
2562
2548
  } # with parenthesis
2563
2549
  ])
2564
2550
  return canvas_answers
2565
-
2551
+
2566
2552
  def get_display_string(self) -> str:
2567
2553
  return ", ".join(
2568
- str(self.fix_negative_zero(round(v, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS))).rstrip('0').rstrip('.') for v in self.value
2554
+ str(self.fix_negative_zero(round(v, Answer.DEFAULT_ROUNDING_DIGITS))).rstrip('0').rstrip('.') for v in self.value
2569
2555
  )
2570
-
2571
- class CompoundAnswers(ContentAST.Answer):
2556
+
2557
+ class CompoundAnswers(Answer):
2572
2558
  pass
2573
2559
  """
2574
2560
  Going forward, this might make a lot of sense to have a SubAnswer class that we can iterate over.
2575
2561
  We would convert into this shared format and just iterate over it whenever we need to.
2576
2562
  """
2577
-
2563
+
2578
2564
  class Matrix(CompoundAnswers):
2579
2565
  """
2580
2566
  Matrix answers generate multiple blank_ids (e.g., M_0_0, M_0_1, M_1_0, M_1_1).
2581
2567
  """
2582
-
2568
+
2583
2569
  def __init__(self, value, *args, **kwargs):
2584
2570
  super().__init__(value=value, *args, **kwargs)
2585
-
2571
+
2586
2572
  self.data = [
2587
2573
  [
2588
2574
  AnswerTypes.Float(
@@ -2593,26 +2579,25 @@ class AnswerTypes:
2593
2579
  ]
2594
2580
  for i in range(self.value.shape[0])
2595
2581
  ]
2596
-
2582
+
2597
2583
  def get_for_canvas(self, single_answer=False) -> List[dict]:
2598
2584
  """Generate Canvas answers for each matrix element."""
2599
2585
  canvas_answers = []
2600
-
2586
+
2601
2587
  for sub_answer in itertools.chain.from_iterable(self.data):
2602
2588
  canvas_answers.extend(sub_answer.get_for_canvas())
2603
-
2604
- return canvas_answers
2605
2589
 
2590
+ return canvas_answers
2591
+
2606
2592
  def render(self, *args, **kwargs) -> str:
2607
- table = ContentAST.Table(self.data)
2608
-
2593
+ table = Table(self.data)
2594
+
2609
2595
  if self.label:
2610
- return ContentAST.Container(
2596
+ return Container(
2611
2597
  [
2612
- ContentAST.Text(f"{self.label} = "),
2598
+ Text(f"{self.label} = "),
2613
2599
  table
2614
2600
  ]
2615
2601
  ).render(*args, **kwargs)
2616
2602
  return table.render(*args, **kwargs)
2617
-
2618
-
2603
+