QuizGenerator 0.1.3__py3-none-any.whl → 0.3.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.
- QuizGenerator/canvas/__init__.py +2 -2
- QuizGenerator/canvas/canvas_interface.py +6 -1
- QuizGenerator/contentast.py +425 -279
- QuizGenerator/generate.py +18 -1
- QuizGenerator/misc.py +122 -23
- QuizGenerator/mixins.py +10 -1
- QuizGenerator/premade_questions/cst334/memory_questions.py +6 -4
- QuizGenerator/premade_questions/cst334/process.py +1 -2
- QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
- QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
- QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
- QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
- QuizGenerator/premade_questions/cst463/models/text.py +201 -0
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +138 -94
- QuizGenerator/question.py +25 -11
- QuizGenerator/quiz.py +0 -1
- {quizgenerator-0.1.3.dist-info → quizgenerator-0.3.0.dist-info}/METADATA +3 -1
- {quizgenerator-0.1.3.dist-info → quizgenerator-0.3.0.dist-info}/RECORD +23 -16
- {quizgenerator-0.1.3.dist-info → quizgenerator-0.3.0.dist-info}/WHEEL +1 -1
- {quizgenerator-0.1.3.dist-info → quizgenerator-0.3.0.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.1.3.dist-info → quizgenerator-0.3.0.dist-info}/licenses/LICENSE +0 -0
QuizGenerator/contentast.py
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import abc
|
|
4
|
+
import enum
|
|
3
5
|
import re
|
|
4
6
|
import textwrap
|
|
5
7
|
from io import BytesIO
|
|
6
8
|
from typing import List, Callable
|
|
7
9
|
|
|
10
|
+
import numpy
|
|
8
11
|
import pypandoc
|
|
9
12
|
import markdown
|
|
10
13
|
|
|
11
|
-
from QuizGenerator.misc import
|
|
14
|
+
# from QuizGenerator.misc import Answer
|
|
12
15
|
|
|
13
16
|
from QuizGenerator.qrcode_generator import QuestionQRCode
|
|
14
17
|
import re
|
|
15
18
|
|
|
19
|
+
import logging
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
16
22
|
class ContentAST:
|
|
17
23
|
"""
|
|
18
24
|
Content Abstract Syntax Tree - The core content system for quiz generation.
|
|
@@ -45,7 +51,13 @@ class ContentAST:
|
|
|
45
51
|
body.add_element(ContentAST.Text("\\\\begin{bmatrix} 1 & 2 \\\\\\\\ 3 & 4 \\\\end{bmatrix}"))
|
|
46
52
|
"""
|
|
47
53
|
|
|
48
|
-
class
|
|
54
|
+
class OutputFormat(enum.StrEnum):
|
|
55
|
+
HTML = "html"
|
|
56
|
+
TYPST = "typst"
|
|
57
|
+
LATEX = "latex"
|
|
58
|
+
MARKDOWN = "markdown"
|
|
59
|
+
|
|
60
|
+
class Element(abc.ABC):
|
|
49
61
|
"""
|
|
50
62
|
Base class for all ContentAST elements providing cross-format rendering.
|
|
51
63
|
|
|
@@ -75,78 +87,92 @@ class ContentAST:
|
|
|
75
87
|
html_output = section.render("html")
|
|
76
88
|
"""
|
|
77
89
|
def __init__(self, elements=None, add_spacing_before=False):
|
|
78
|
-
|
|
79
|
-
self.
|
|
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
|
|
80
96
|
|
|
81
97
|
def __str__(self):
|
|
82
98
|
return self.render_markdown()
|
|
83
99
|
|
|
84
|
-
def
|
|
85
|
-
|
|
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:
|
|
100
|
+
def render(self, output_format : ContentAST.OutputFormat, **kwargs) -> str:
|
|
101
|
+
# Render using the appropriate method, if it exists
|
|
122
102
|
method_name = f"render_{output_format}"
|
|
123
103
|
if hasattr(self, method_name):
|
|
124
104
|
return getattr(self, method_name)(**kwargs)
|
|
125
105
|
|
|
126
106
|
return self.render_markdown(**kwargs) # Fallback to markdown
|
|
127
107
|
|
|
108
|
+
@abc.abstractmethod
|
|
128
109
|
def render_markdown(self, **kwargs):
|
|
129
|
-
|
|
110
|
+
pass
|
|
130
111
|
|
|
112
|
+
@abc.abstractmethod
|
|
131
113
|
def render_html(self, **kwargs):
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return
|
|
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 []
|
|
144
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
|
+
|
|
145
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
|
+
|
|
146
166
|
latex = "".join(element.render("latex", **kwargs) for element in self.elements)
|
|
147
167
|
return f"{'\n\n\\vspace{0.5cm}' if self.add_spacing_before else ''}{latex}"
|
|
148
|
-
|
|
168
|
+
|
|
149
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
|
+
|
|
150
176
|
"""
|
|
151
177
|
Default Typst rendering using markdown → typst conversion via pandoc.
|
|
152
178
|
|
|
@@ -157,21 +183,67 @@ class ContentAST:
|
|
|
157
183
|
"""
|
|
158
184
|
# Render to markdown first
|
|
159
185
|
markdown_content = self.render_markdown(**kwargs)
|
|
160
|
-
|
|
186
|
+
|
|
161
187
|
# Convert markdown to Typst via pandoc
|
|
162
188
|
typst_content = self.convert_markdown(markdown_content, 'typst')
|
|
163
|
-
|
|
189
|
+
|
|
164
190
|
# Add spacing if needed (Typst equivalent of \vspace)
|
|
165
191
|
if self.add_spacing_before:
|
|
166
192
|
return f"\n{typst_content}"
|
|
167
|
-
|
|
193
|
+
|
|
168
194
|
return typst_content if typst_content else markdown_content
|
|
169
195
|
|
|
170
|
-
|
|
171
|
-
|
|
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"\#")
|
|
172
244
|
|
|
173
|
-
##
|
|
174
|
-
class Document(
|
|
245
|
+
## Top-ish Level containers
|
|
246
|
+
class Document(Container):
|
|
175
247
|
"""
|
|
176
248
|
Root document container for complete quiz documents with proper headers and structure.
|
|
177
249
|
|
|
@@ -376,7 +448,7 @@ class ContentAST:
|
|
|
376
448
|
|
|
377
449
|
""")
|
|
378
450
|
|
|
379
|
-
latex += "\n".join(element.render(
|
|
451
|
+
latex += "\n".join(element.render(ContentAST.OutputFormat.LATEX, **kwargs) for element in self.elements)
|
|
380
452
|
|
|
381
453
|
latex += r"\end{document}"
|
|
382
454
|
|
|
@@ -396,11 +468,11 @@ class ContentAST:
|
|
|
396
468
|
typst += f"#v(0.5cm)\n"
|
|
397
469
|
|
|
398
470
|
# Render all elements
|
|
399
|
-
typst += "".join(element.render(
|
|
471
|
+
typst += "".join(element.render(ContentAST.OutputFormat.TYPST, **kwargs) for element in self.elements)
|
|
400
472
|
|
|
401
473
|
return typst
|
|
402
474
|
|
|
403
|
-
class Question(
|
|
475
|
+
class Question(Container):
|
|
404
476
|
"""
|
|
405
477
|
Complete question container with body, explanation, and metadata.
|
|
406
478
|
|
|
@@ -436,7 +508,8 @@ class ContentAST:
|
|
|
436
508
|
interest=1.0,
|
|
437
509
|
spacing=0,
|
|
438
510
|
topic=None,
|
|
439
|
-
question_number=None
|
|
511
|
+
question_number=None,
|
|
512
|
+
**kwargs
|
|
440
513
|
):
|
|
441
514
|
super().__init__()
|
|
442
515
|
self.name = name
|
|
@@ -447,14 +520,21 @@ class ContentAST:
|
|
|
447
520
|
self.spacing = spacing
|
|
448
521
|
self.topic = topic # todo: remove this bs.
|
|
449
522
|
self.question_number = question_number # For QR code generation
|
|
523
|
+
|
|
524
|
+
self.default_kwargs = kwargs
|
|
450
525
|
|
|
451
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
|
+
|
|
452
532
|
# Special handling for latex and typst - use dedicated render methods
|
|
453
533
|
if output_format == "typst":
|
|
454
534
|
return self.render_typst(**kwargs)
|
|
455
535
|
|
|
456
536
|
# Generate content from all elements
|
|
457
|
-
content = self.body.render(output_format, **
|
|
537
|
+
content = self.body.render(output_format, **updated_kwargs)
|
|
458
538
|
|
|
459
539
|
# If output format is latex, add in minipage and question environments
|
|
460
540
|
if output_format == "latex":
|
|
@@ -522,7 +602,7 @@ class ContentAST:
|
|
|
522
602
|
def render_typst(self, **kwargs):
|
|
523
603
|
"""Render question in Typst format with proper formatting"""
|
|
524
604
|
# Render question body
|
|
525
|
-
content = self.body.render(
|
|
605
|
+
content = self.body.render(ContentAST.OutputFormat.TYPST, **kwargs)
|
|
526
606
|
|
|
527
607
|
# Generate QR code if question number is available
|
|
528
608
|
qr_param = ""
|
|
@@ -564,9 +644,40 @@ class ContentAST:
|
|
|
564
644
|
{qr_param}
|
|
565
645
|
)[
|
|
566
646
|
""") + content + "\n]\n\n"
|
|
567
|
-
|
|
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
|
+
|
|
568
679
|
# Individual elements
|
|
569
|
-
class Text(
|
|
680
|
+
class Text(Leaf):
|
|
570
681
|
"""
|
|
571
682
|
Basic text content with automatic format conversion and selective visibility.
|
|
572
683
|
|
|
@@ -595,8 +706,7 @@ class ContentAST:
|
|
|
595
706
|
web_note = ContentAST.Text("Click submit", hide_from_latex=True)
|
|
596
707
|
"""
|
|
597
708
|
def __init__(self, content : str, *, hide_from_latex=False, hide_from_html=False, emphasis=False):
|
|
598
|
-
super().__init__()
|
|
599
|
-
self.content = content
|
|
709
|
+
super().__init__(content)
|
|
600
710
|
self.hide_from_latex = hide_from_latex
|
|
601
711
|
self.hide_from_html = hide_from_html
|
|
602
712
|
self.emphasis = emphasis
|
|
@@ -607,26 +717,19 @@ class ContentAST:
|
|
|
607
717
|
def render_html(self, **kwargs):
|
|
608
718
|
if self.hide_from_html:
|
|
609
719
|
return ""
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
720
|
+
return self.convert_markdown(self.content,ContentAST.OutputFormat.HTML)
|
|
721
|
+
|
|
617
722
|
def render_latex(self, **kwargs):
|
|
618
723
|
if self.hide_from_latex:
|
|
619
724
|
return ""
|
|
620
|
-
|
|
621
|
-
content = super().convert_markdown(self.content.replace("#", r"\#"), "latex") or self.content
|
|
622
|
-
return content
|
|
725
|
+
return self.convert_markdown(self.content.replace("#", r"\#"), ContentAST.OutputFormat.LATEX)
|
|
623
726
|
|
|
624
727
|
def render_typst(self, **kwargs):
|
|
625
728
|
"""Render text to Typst, escaping special characters."""
|
|
626
|
-
# Hide HTML-only text from Typst (since Typst generates PDFs like LaTeX)
|
|
627
729
|
if self.hide_from_latex:
|
|
628
730
|
return ""
|
|
629
731
|
|
|
732
|
+
# This is for when we are passing in a code block via a FromText question
|
|
630
733
|
content = re.sub(
|
|
631
734
|
r"```\s*(.*)\s*```",
|
|
632
735
|
r"""
|
|
@@ -642,12 +745,9 @@ class ContentAST:
|
|
|
642
745
|
|
|
643
746
|
# In Typst, # starts code/function calls, so we need to escape it
|
|
644
747
|
content = content.replace("# ", r"\# ")
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
# Apply emphasis if needed
|
|
748
|
+
|
|
648
749
|
if self.emphasis:
|
|
649
750
|
content = f"*{content}*"
|
|
650
|
-
|
|
651
751
|
return content
|
|
652
752
|
|
|
653
753
|
def is_mergeable(self, other: ContentAST.Element):
|
|
@@ -695,31 +795,25 @@ class ContentAST:
|
|
|
695
795
|
self.make_normal = kwargs.get("make_normal", False)
|
|
696
796
|
|
|
697
797
|
def render_markdown(self, **kwargs):
|
|
698
|
-
content = "
|
|
798
|
+
content = "```" + self.content.rstrip() + "\n```"
|
|
699
799
|
return content
|
|
700
800
|
|
|
701
801
|
def render_html(self, **kwargs):
|
|
702
|
-
|
|
703
|
-
return super().convert_markdown(textwrap.indent(self.content, "\t"), "html") or self.content
|
|
802
|
+
return self.convert_markdown(textwrap.indent(self.content, "\t"), ContentAST.OutputFormat.HTML)
|
|
704
803
|
|
|
705
804
|
def render_latex(self, **kwargs):
|
|
706
|
-
|
|
707
|
-
if self.make_normal:
|
|
708
|
-
content = (
|
|
709
|
-
r"{\normal "
|
|
710
|
-
+ content +
|
|
711
|
-
r"}"
|
|
712
|
-
)
|
|
713
|
-
return content
|
|
805
|
+
return self.convert_markdown(self.render_markdown(), ContentAST.OutputFormat.LATEX)
|
|
714
806
|
|
|
715
807
|
def render_typst(self, **kwargs):
|
|
716
808
|
"""Render code block in Typst with smaller monospace font."""
|
|
717
809
|
# Use raw block with 11pt font size
|
|
718
810
|
# Escape backticks in the content
|
|
719
811
|
escaped_content = self.content.replace("`", r"\`")
|
|
720
|
-
|
|
812
|
+
|
|
813
|
+
# Try to reduce individual pathway to ensure consistency
|
|
814
|
+
return ContentAST.Text(f"```\n{escaped_content.rstrip()}\n```").render_typst()
|
|
721
815
|
|
|
722
|
-
class Equation(
|
|
816
|
+
class Equation(Leaf):
|
|
723
817
|
"""
|
|
724
818
|
Mathematical equation renderer with LaTeX input and cross-format output.
|
|
725
819
|
|
|
@@ -748,7 +842,7 @@ class ContentAST:
|
|
|
748
842
|
body.add_element(ContentAST.Equation(r"\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}"))
|
|
749
843
|
"""
|
|
750
844
|
def __init__(self, latex, inline=False):
|
|
751
|
-
super().__init__()
|
|
845
|
+
super().__init__("[equation]")
|
|
752
846
|
self.latex = latex
|
|
753
847
|
self.inline = inline
|
|
754
848
|
|
|
@@ -861,7 +955,7 @@ class ContentAST:
|
|
|
861
955
|
|
|
862
956
|
return cls('\n'.join(equation_lines))
|
|
863
957
|
|
|
864
|
-
class Matrix(
|
|
958
|
+
class Matrix(Leaf):
|
|
865
959
|
"""
|
|
866
960
|
Mathematical matrix renderer for consistent cross-format display.
|
|
867
961
|
|
|
@@ -891,21 +985,36 @@ class ContentAST:
|
|
|
891
985
|
- "B": curly braces {matrix}
|
|
892
986
|
- "V": double vertical bars ||matrix|| - for norms
|
|
893
987
|
"""
|
|
894
|
-
def __init__(self, data, bracket_type="p", inline=False):
|
|
988
|
+
def __init__(self, data, *, bracket_type="p", inline=False, name=None):
|
|
895
989
|
"""
|
|
896
990
|
Creates a matrix element that renders consistently across output formats.
|
|
897
991
|
|
|
898
992
|
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]]
|
|
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]])
|
|
902
996
|
bracket_type: Bracket style - "b" for [], "p" for (), "v" for |, etc.
|
|
903
997
|
inline: Whether to use inline (smaller) matrix formatting
|
|
904
998
|
"""
|
|
905
|
-
super().__init__()
|
|
906
|
-
|
|
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
|
+
|
|
907
1015
|
self.bracket_type = bracket_type
|
|
908
1016
|
self.inline = inline
|
|
1017
|
+
self.name = name
|
|
909
1018
|
|
|
910
1019
|
@staticmethod
|
|
911
1020
|
def to_latex(data, bracket_type="p"):
|
|
@@ -944,14 +1053,19 @@ class ContentAST:
|
|
|
944
1053
|
def render_html(self, **kwargs):
|
|
945
1054
|
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
946
1055
|
rows = []
|
|
947
|
-
|
|
1056
|
+
if isinstance(self.data, numpy.ndarray):
|
|
1057
|
+
data = self.data.tolist()
|
|
1058
|
+
else:
|
|
1059
|
+
data = self.data
|
|
1060
|
+
for row in data:
|
|
948
1061
|
rows.append(" & ".join(str(cell) for cell in row))
|
|
949
1062
|
matrix_content = r" \\ ".join(rows)
|
|
950
1063
|
|
|
951
1064
|
if self.inline:
|
|
952
1065
|
return f"<span class='math'>$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$</span>"
|
|
953
1066
|
else:
|
|
954
|
-
|
|
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>"
|
|
955
1069
|
|
|
956
1070
|
def render_latex(self, **kwargs):
|
|
957
1071
|
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
@@ -965,7 +1079,41 @@ class ContentAST:
|
|
|
965
1079
|
else:
|
|
966
1080
|
return f"\\[\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\]"
|
|
967
1081
|
|
|
968
|
-
|
|
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):
|
|
969
1117
|
"""
|
|
970
1118
|
Image/diagram container with proper sizing and captioning.
|
|
971
1119
|
|
|
@@ -997,7 +1145,7 @@ class ContentAST:
|
|
|
997
1145
|
body.add_element(picture)
|
|
998
1146
|
"""
|
|
999
1147
|
def __init__(self, img_data, caption=None, width=None):
|
|
1000
|
-
super().__init__()
|
|
1148
|
+
super().__init__("[picture]")
|
|
1001
1149
|
self.img_data = img_data
|
|
1002
1150
|
self.caption = caption
|
|
1003
1151
|
self.width = width
|
|
@@ -1060,8 +1208,165 @@ class ContentAST:
|
|
|
1060
1208
|
|
|
1061
1209
|
result.append("\\end{figure}")
|
|
1062
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))
|
|
1063
1368
|
|
|
1064
|
-
class Table(
|
|
1369
|
+
class Table(Container):
|
|
1065
1370
|
"""
|
|
1066
1371
|
Structured data table with cross-format rendering and proper formatting.
|
|
1067
1372
|
|
|
@@ -1291,166 +1596,7 @@ class ContentAST:
|
|
|
1291
1596
|
|
|
1292
1597
|
return "\n#box(" + "\n".join(result) + "\n)"
|
|
1293
1598
|
|
|
1294
|
-
|
|
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):
|
|
1599
|
+
class TableGroup(Container):
|
|
1454
1600
|
"""
|
|
1455
1601
|
Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
|
|
1456
1602
|
|
|
@@ -1638,7 +1784,7 @@ class ContentAST:
|
|
|
1638
1784
|
return content
|
|
1639
1785
|
|
|
1640
1786
|
## Specialized Elements
|
|
1641
|
-
class RepeatedProblemPart(
|
|
1787
|
+
class RepeatedProblemPart(Container):
|
|
1642
1788
|
"""
|
|
1643
1789
|
Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
|
|
1644
1790
|
|
|
@@ -1753,7 +1899,7 @@ class ContentAST:
|
|
|
1753
1899
|
result.append(r"\end{alignat*}")
|
|
1754
1900
|
return "\n".join(result)
|
|
1755
1901
|
|
|
1756
|
-
class OnlyLatex(
|
|
1902
|
+
class OnlyLatex(Container):
|
|
1757
1903
|
"""
|
|
1758
1904
|
Container element that only renders content in LaTeX/PDF output format.
|
|
1759
1905
|
|
|
@@ -1775,12 +1921,12 @@ class ContentAST:
|
|
|
1775
1921
|
body.add_element(latex_only)
|
|
1776
1922
|
"""
|
|
1777
1923
|
|
|
1778
|
-
def render(self, output_format, **kwargs):
|
|
1924
|
+
def render(self, output_format: ContentAST.OutputFormat, **kwargs):
|
|
1779
1925
|
if output_format != "latex":
|
|
1780
1926
|
return ""
|
|
1781
|
-
return super().render(output_format, **kwargs)
|
|
1927
|
+
return super().render(output_format=output_format, **kwargs)
|
|
1782
1928
|
|
|
1783
|
-
class OnlyHtml(
|
|
1929
|
+
class OnlyHtml(Container):
|
|
1784
1930
|
"""
|
|
1785
1931
|
Container element that only renders content in HTML/Canvas output format.
|
|
1786
1932
|
|