QuizGenerator 0.4.2__py3-none-any.whl

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