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