QuizGenerator 0.1.4__py3-none-any.whl → 0.3.1__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/contentast.py +415 -284
- QuizGenerator/misc.py +96 -8
- QuizGenerator/mixins.py +10 -1
- QuizGenerator/premade_questions/cst334/memory_questions.py +1 -1
- QuizGenerator/premade_questions/cst334/persistence_questions.py +78 -23
- 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 +3 -2
- QuizGenerator/quiz.py +0 -1
- {quizgenerator-0.1.4.dist-info → quizgenerator-0.3.1.dist-info}/METADATA +3 -1
- {quizgenerator-0.1.4.dist-info → quizgenerator-0.3.1.dist-info}/RECORD +21 -14
- {quizgenerator-0.1.4.dist-info → quizgenerator-0.3.1.dist-info}/WHEEL +1 -1
- {quizgenerator-0.1.4.dist-info → quizgenerator-0.3.1.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.1.4.dist-info → quizgenerator-0.3.1.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,81 +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
|
-
|
|
80
|
-
|
|
81
|
-
]
|
|
82
|
-
|
|
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
|
|
83
96
|
|
|
84
97
|
def __str__(self):
|
|
85
98
|
return self.render_markdown()
|
|
86
99
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def add_elements(self, elements, new_paragraph=False):
|
|
91
|
-
if new_paragraph:
|
|
92
|
-
self.elements.append(ContentAST.Text(""))
|
|
93
|
-
self.elements.extend(elements)
|
|
94
|
-
|
|
95
|
-
def convert_markdown(self, str_to_convert, output_format):
|
|
96
|
-
if output_format == "html":
|
|
97
|
-
# Fast in-process HTML conversion using Python markdown library
|
|
98
|
-
try:
|
|
99
|
-
# Convert markdown to HTML using fast Python library
|
|
100
|
-
html_output = markdown.markdown(str_to_convert)
|
|
101
|
-
|
|
102
|
-
# Strip surrounding <p> tags for inline content (matching old behavior)
|
|
103
|
-
if html_output.startswith("<p>") and html_output.endswith("</p>"):
|
|
104
|
-
html_output = html_output[3:-4]
|
|
105
|
-
|
|
106
|
-
return html_output.strip()
|
|
107
|
-
except Exception as e:
|
|
108
|
-
log.warning(f"Markdown conversion failed: {e}. Returning original content.")
|
|
109
|
-
return str_to_convert
|
|
110
|
-
else:
|
|
111
|
-
# Keep using Pandoc for LaTeX and other formats (less critical path)
|
|
112
|
-
try:
|
|
113
|
-
output = pypandoc.convert_text(
|
|
114
|
-
str_to_convert,
|
|
115
|
-
output_format,
|
|
116
|
-
format='md',
|
|
117
|
-
extra_args=["-M2GB", "+RTS", "-K64m", "-RTS"]
|
|
118
|
-
)
|
|
119
|
-
return output
|
|
120
|
-
except RuntimeError as e:
|
|
121
|
-
log.warning(f"Specified conversion format '{output_format}' not recognized by pypandoc. Defaulting to markdown")
|
|
122
|
-
return None
|
|
123
|
-
|
|
124
|
-
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
|
|
125
102
|
method_name = f"render_{output_format}"
|
|
126
103
|
if hasattr(self, method_name):
|
|
127
104
|
return getattr(self, method_name)(**kwargs)
|
|
128
105
|
|
|
129
106
|
return self.render_markdown(**kwargs) # Fallback to markdown
|
|
130
107
|
|
|
108
|
+
@abc.abstractmethod
|
|
131
109
|
def render_markdown(self, **kwargs):
|
|
132
|
-
|
|
110
|
+
pass
|
|
133
111
|
|
|
112
|
+
@abc.abstractmethod
|
|
134
113
|
def render_html(self, **kwargs):
|
|
135
|
-
|
|
136
|
-
html_parts = []
|
|
137
|
-
for element in self.elements:
|
|
138
|
-
try:
|
|
139
|
-
html_parts.append(element.render("html", **kwargs))
|
|
140
|
-
except AttributeError:
|
|
141
|
-
log.error(f"That's the one: \"{element.__class__}\" \"{element}\"")
|
|
142
|
-
exit(8)
|
|
143
|
-
|
|
144
|
-
html = " ".join(html_parts)
|
|
145
|
-
#html = " ".join(element.render("html", **kwargs) for element in self.elements)
|
|
146
|
-
return f"{'<br>' if self.add_spacing_before else ''}{html}"
|
|
114
|
+
pass
|
|
147
115
|
|
|
116
|
+
@abc.abstractmethod
|
|
148
117
|
def render_latex(self, **kwargs):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
@abc.abstractmethod
|
|
121
|
+
def render_typst(self, **kwargs):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def is_mergeable(self, other: ContentAST.Element):
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
class Container(Element):
|
|
128
|
+
"""Elements that contain other elements. Generally are formatting of larger pieces."""
|
|
129
|
+
def __init__(self, elements=None, **kwargs):
|
|
130
|
+
super().__init__(**kwargs)
|
|
131
|
+
self.elements : List[ContentAST.Element] = elements if elements is not None else []
|
|
132
|
+
|
|
133
|
+
def add_element(self, element):
|
|
134
|
+
self.elements.append(element)
|
|
135
|
+
|
|
136
|
+
def add_elements(self, elements):
|
|
137
|
+
self.elements.extend(elements)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def render_element(element, output_format: ContentAST.OutputFormat, **kwargs):
|
|
141
|
+
if isinstance(element, ContentAST.Element):
|
|
142
|
+
return element.render(output_format, **kwargs)
|
|
143
|
+
log.warning(f"Element ({element}) is not ContentAST.Element. Defaulting to forcing to a string.")
|
|
144
|
+
return f"{element}"
|
|
145
|
+
|
|
146
|
+
def render_markdown(self, **kwargs):
|
|
147
|
+
return " ".join([
|
|
148
|
+
self.render_element(element, output_format=ContentAST.OutputFormat.MARKDOWN, **kwargs)
|
|
149
|
+
for element in self.elements
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
def render_html(self, **kwargs):
|
|
153
|
+
for element in self.elements:
|
|
154
|
+
log.debug(f"element: {element}")
|
|
155
|
+
return " ".join([
|
|
156
|
+
self.render_element(element, output_format=ContentAST.OutputFormat.HTML, **kwargs)
|
|
157
|
+
for element in self.elements
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
def render_latex(self, **kwargs):
|
|
161
|
+
return "".join([
|
|
162
|
+
self.render_element(element, output_format=ContentAST.OutputFormat.LATEX, **kwargs)
|
|
163
|
+
for element in self.elements
|
|
164
|
+
])
|
|
165
|
+
|
|
149
166
|
latex = "".join(element.render("latex", **kwargs) for element in self.elements)
|
|
150
167
|
return f"{'\n\n\\vspace{0.5cm}' if self.add_spacing_before else ''}{latex}"
|
|
151
|
-
|
|
168
|
+
|
|
152
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
|
+
|
|
153
176
|
"""
|
|
154
177
|
Default Typst rendering using markdown → typst conversion via pandoc.
|
|
155
178
|
|
|
@@ -160,21 +183,67 @@ class ContentAST:
|
|
|
160
183
|
"""
|
|
161
184
|
# Render to markdown first
|
|
162
185
|
markdown_content = self.render_markdown(**kwargs)
|
|
163
|
-
|
|
186
|
+
|
|
164
187
|
# Convert markdown to Typst via pandoc
|
|
165
188
|
typst_content = self.convert_markdown(markdown_content, 'typst')
|
|
166
|
-
|
|
189
|
+
|
|
167
190
|
# Add spacing if needed (Typst equivalent of \vspace)
|
|
168
191
|
if self.add_spacing_before:
|
|
169
192
|
return f"\n{typst_content}"
|
|
170
|
-
|
|
193
|
+
|
|
171
194
|
return typst_content if typst_content else markdown_content
|
|
172
195
|
|
|
173
|
-
|
|
174
|
-
|
|
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"\#")
|
|
175
244
|
|
|
176
|
-
##
|
|
177
|
-
class Document(
|
|
245
|
+
## Top-ish Level containers
|
|
246
|
+
class Document(Container):
|
|
178
247
|
"""
|
|
179
248
|
Root document container for complete quiz documents with proper headers and structure.
|
|
180
249
|
|
|
@@ -379,7 +448,7 @@ class ContentAST:
|
|
|
379
448
|
|
|
380
449
|
""")
|
|
381
450
|
|
|
382
|
-
latex += "\n".join(element.render(
|
|
451
|
+
latex += "\n".join(element.render(ContentAST.OutputFormat.LATEX, **kwargs) for element in self.elements)
|
|
383
452
|
|
|
384
453
|
latex += r"\end{document}"
|
|
385
454
|
|
|
@@ -399,11 +468,11 @@ class ContentAST:
|
|
|
399
468
|
typst += f"#v(0.5cm)\n"
|
|
400
469
|
|
|
401
470
|
# Render all elements
|
|
402
|
-
typst += "".join(element.render(
|
|
471
|
+
typst += "".join(element.render(ContentAST.OutputFormat.TYPST, **kwargs) for element in self.elements)
|
|
403
472
|
|
|
404
473
|
return typst
|
|
405
474
|
|
|
406
|
-
class Question(
|
|
475
|
+
class Question(Container):
|
|
407
476
|
"""
|
|
408
477
|
Complete question container with body, explanation, and metadata.
|
|
409
478
|
|
|
@@ -533,7 +602,7 @@ class ContentAST:
|
|
|
533
602
|
def render_typst(self, **kwargs):
|
|
534
603
|
"""Render question in Typst format with proper formatting"""
|
|
535
604
|
# Render question body
|
|
536
|
-
content = self.body.render(
|
|
605
|
+
content = self.body.render(ContentAST.OutputFormat.TYPST, **kwargs)
|
|
537
606
|
|
|
538
607
|
# Generate QR code if question number is available
|
|
539
608
|
qr_param = ""
|
|
@@ -575,9 +644,40 @@ class ContentAST:
|
|
|
575
644
|
{qr_param}
|
|
576
645
|
)[
|
|
577
646
|
""") + content + "\n]\n\n"
|
|
578
|
-
|
|
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
|
+
|
|
579
679
|
# Individual elements
|
|
580
|
-
class Text(
|
|
680
|
+
class Text(Leaf):
|
|
581
681
|
"""
|
|
582
682
|
Basic text content with automatic format conversion and selective visibility.
|
|
583
683
|
|
|
@@ -606,8 +706,7 @@ class ContentAST:
|
|
|
606
706
|
web_note = ContentAST.Text("Click submit", hide_from_latex=True)
|
|
607
707
|
"""
|
|
608
708
|
def __init__(self, content : str, *, hide_from_latex=False, hide_from_html=False, emphasis=False):
|
|
609
|
-
super().__init__()
|
|
610
|
-
self.content = content
|
|
709
|
+
super().__init__(content)
|
|
611
710
|
self.hide_from_latex = hide_from_latex
|
|
612
711
|
self.hide_from_html = hide_from_html
|
|
613
712
|
self.emphasis = emphasis
|
|
@@ -618,26 +717,19 @@ class ContentAST:
|
|
|
618
717
|
def render_html(self, **kwargs):
|
|
619
718
|
if self.hide_from_html:
|
|
620
719
|
return ""
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if conversion_results is not None:
|
|
624
|
-
if conversion_results.startswith("<p>") and conversion_results.endswith("</p>"):
|
|
625
|
-
conversion_results = conversion_results[3:-4]
|
|
626
|
-
return conversion_results or self.content
|
|
627
|
-
|
|
720
|
+
return self.convert_markdown(self.content,ContentAST.OutputFormat.HTML)
|
|
721
|
+
|
|
628
722
|
def render_latex(self, **kwargs):
|
|
629
723
|
if self.hide_from_latex:
|
|
630
724
|
return ""
|
|
631
|
-
|
|
632
|
-
content = super().convert_markdown(self.content.replace("#", r"\#"), "latex") or self.content
|
|
633
|
-
return content
|
|
725
|
+
return self.convert_markdown(self.content.replace("#", r"\#"), ContentAST.OutputFormat.LATEX)
|
|
634
726
|
|
|
635
727
|
def render_typst(self, **kwargs):
|
|
636
728
|
"""Render text to Typst, escaping special characters."""
|
|
637
|
-
# Hide HTML-only text from Typst (since Typst generates PDFs like LaTeX)
|
|
638
729
|
if self.hide_from_latex:
|
|
639
730
|
return ""
|
|
640
731
|
|
|
732
|
+
# This is for when we are passing in a code block via a FromText question
|
|
641
733
|
content = re.sub(
|
|
642
734
|
r"```\s*(.*)\s*```",
|
|
643
735
|
r"""
|
|
@@ -653,12 +745,9 @@ class ContentAST:
|
|
|
653
745
|
|
|
654
746
|
# In Typst, # starts code/function calls, so we need to escape it
|
|
655
747
|
content = content.replace("# ", r"\# ")
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
# Apply emphasis if needed
|
|
748
|
+
|
|
659
749
|
if self.emphasis:
|
|
660
750
|
content = f"*{content}*"
|
|
661
|
-
|
|
662
751
|
return content
|
|
663
752
|
|
|
664
753
|
def is_mergeable(self, other: ContentAST.Element):
|
|
@@ -706,31 +795,25 @@ class ContentAST:
|
|
|
706
795
|
self.make_normal = kwargs.get("make_normal", False)
|
|
707
796
|
|
|
708
797
|
def render_markdown(self, **kwargs):
|
|
709
|
-
content = "
|
|
798
|
+
content = "```" + self.content.rstrip() + "\n```"
|
|
710
799
|
return content
|
|
711
800
|
|
|
712
801
|
def render_html(self, **kwargs):
|
|
713
|
-
|
|
714
|
-
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)
|
|
715
803
|
|
|
716
804
|
def render_latex(self, **kwargs):
|
|
717
|
-
|
|
718
|
-
if self.make_normal:
|
|
719
|
-
content = (
|
|
720
|
-
r"{\normal "
|
|
721
|
-
+ content +
|
|
722
|
-
r"}"
|
|
723
|
-
)
|
|
724
|
-
return content
|
|
805
|
+
return self.convert_markdown(self.render_markdown(), ContentAST.OutputFormat.LATEX)
|
|
725
806
|
|
|
726
807
|
def render_typst(self, **kwargs):
|
|
727
808
|
"""Render code block in Typst with smaller monospace font."""
|
|
728
809
|
# Use raw block with 11pt font size
|
|
729
810
|
# Escape backticks in the content
|
|
730
811
|
escaped_content = self.content.replace("`", r"\`")
|
|
731
|
-
|
|
812
|
+
|
|
813
|
+
# Try to reduce individual pathway to ensure consistency
|
|
814
|
+
return ContentAST.Text(f"```\n{escaped_content.rstrip()}\n```").render_typst()
|
|
732
815
|
|
|
733
|
-
class Equation(
|
|
816
|
+
class Equation(Leaf):
|
|
734
817
|
"""
|
|
735
818
|
Mathematical equation renderer with LaTeX input and cross-format output.
|
|
736
819
|
|
|
@@ -759,7 +842,7 @@ class ContentAST:
|
|
|
759
842
|
body.add_element(ContentAST.Equation(r"\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}"))
|
|
760
843
|
"""
|
|
761
844
|
def __init__(self, latex, inline=False):
|
|
762
|
-
super().__init__()
|
|
845
|
+
super().__init__("[equation]")
|
|
763
846
|
self.latex = latex
|
|
764
847
|
self.inline = inline
|
|
765
848
|
|
|
@@ -872,7 +955,7 @@ class ContentAST:
|
|
|
872
955
|
|
|
873
956
|
return cls('\n'.join(equation_lines))
|
|
874
957
|
|
|
875
|
-
class Matrix(
|
|
958
|
+
class Matrix(Leaf):
|
|
876
959
|
"""
|
|
877
960
|
Mathematical matrix renderer for consistent cross-format display.
|
|
878
961
|
|
|
@@ -902,21 +985,36 @@ class ContentAST:
|
|
|
902
985
|
- "B": curly braces {matrix}
|
|
903
986
|
- "V": double vertical bars ||matrix|| - for norms
|
|
904
987
|
"""
|
|
905
|
-
def __init__(self, data, bracket_type="p", inline=False):
|
|
988
|
+
def __init__(self, data, *, bracket_type="p", inline=False, name=None):
|
|
906
989
|
"""
|
|
907
990
|
Creates a matrix element that renders consistently across output formats.
|
|
908
991
|
|
|
909
992
|
Args:
|
|
910
|
-
data: Matrix data as List[List[numbers/strings]]
|
|
911
|
-
For vectors: [[v1], [v2], [v3]] (column vector)
|
|
912
|
-
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]])
|
|
913
996
|
bracket_type: Bracket style - "b" for [], "p" for (), "v" for |, etc.
|
|
914
997
|
inline: Whether to use inline (smaller) matrix formatting
|
|
915
998
|
"""
|
|
916
|
-
super().__init__()
|
|
917
|
-
|
|
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
|
+
|
|
918
1015
|
self.bracket_type = bracket_type
|
|
919
1016
|
self.inline = inline
|
|
1017
|
+
self.name = name
|
|
920
1018
|
|
|
921
1019
|
@staticmethod
|
|
922
1020
|
def to_latex(data, bracket_type="p"):
|
|
@@ -955,14 +1053,19 @@ class ContentAST:
|
|
|
955
1053
|
def render_html(self, **kwargs):
|
|
956
1054
|
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
957
1055
|
rows = []
|
|
958
|
-
|
|
1056
|
+
if isinstance(self.data, numpy.ndarray):
|
|
1057
|
+
data = self.data.tolist()
|
|
1058
|
+
else:
|
|
1059
|
+
data = self.data
|
|
1060
|
+
for row in data:
|
|
959
1061
|
rows.append(" & ".join(str(cell) for cell in row))
|
|
960
1062
|
matrix_content = r" \\ ".join(rows)
|
|
961
1063
|
|
|
962
1064
|
if self.inline:
|
|
963
1065
|
return f"<span class='math'>$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$</span>"
|
|
964
1066
|
else:
|
|
965
|
-
|
|
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>"
|
|
966
1069
|
|
|
967
1070
|
def render_latex(self, **kwargs):
|
|
968
1071
|
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
@@ -976,7 +1079,41 @@ class ContentAST:
|
|
|
976
1079
|
else:
|
|
977
1080
|
return f"\\[\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\]"
|
|
978
1081
|
|
|
979
|
-
|
|
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):
|
|
980
1117
|
"""
|
|
981
1118
|
Image/diagram container with proper sizing and captioning.
|
|
982
1119
|
|
|
@@ -1008,7 +1145,7 @@ class ContentAST:
|
|
|
1008
1145
|
body.add_element(picture)
|
|
1009
1146
|
"""
|
|
1010
1147
|
def __init__(self, img_data, caption=None, width=None):
|
|
1011
|
-
super().__init__()
|
|
1148
|
+
super().__init__("[picture]")
|
|
1012
1149
|
self.img_data = img_data
|
|
1013
1150
|
self.caption = caption
|
|
1014
1151
|
self.width = width
|
|
@@ -1071,8 +1208,165 @@ class ContentAST:
|
|
|
1071
1208
|
|
|
1072
1209
|
result.append("\\end{figure}")
|
|
1073
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()
|
|
1074
1308
|
|
|
1075
|
-
class
|
|
1309
|
+
class LineBreak(Text):
|
|
1310
|
+
def __init__(self, *args, **kwargs):
|
|
1311
|
+
super().__init__("\n\n")
|
|
1312
|
+
|
|
1313
|
+
## Containers
|
|
1314
|
+
|
|
1315
|
+
class Paragraph(Container):
|
|
1316
|
+
"""
|
|
1317
|
+
Text block container with proper spacing and paragraph formatting.
|
|
1318
|
+
|
|
1319
|
+
IMPORTANT: Use this for grouping text content, especially in question bodies.
|
|
1320
|
+
Automatically handles spacing between paragraphs and combines multiple
|
|
1321
|
+
lines/elements into a cohesive text block.
|
|
1322
|
+
|
|
1323
|
+
When to use:
|
|
1324
|
+
- Question instructions or problem statements
|
|
1325
|
+
- Multi-line text content
|
|
1326
|
+
- Grouping related text elements
|
|
1327
|
+
- Any text that should be visually separated as a paragraph
|
|
1328
|
+
|
|
1329
|
+
When NOT to use:
|
|
1330
|
+
- Single words or short phrases (use ContentAST.Text)
|
|
1331
|
+
- Mathematical content (use ContentAST.Equation)
|
|
1332
|
+
- Structured data (use ContentAST.Table)
|
|
1333
|
+
|
|
1334
|
+
Example:
|
|
1335
|
+
# Multi-line question text
|
|
1336
|
+
body.add_element(ContentAST.Paragraph([
|
|
1337
|
+
"Consider the following system:",
|
|
1338
|
+
"- Process A requires 4MB memory",
|
|
1339
|
+
"- Process B requires 2MB memory",
|
|
1340
|
+
"How much total memory is needed?"
|
|
1341
|
+
]))
|
|
1342
|
+
|
|
1343
|
+
# Mixed content paragraph
|
|
1344
|
+
para = ContentAST.Paragraph([
|
|
1345
|
+
"The equation ",
|
|
1346
|
+
ContentAST.Equation("x^2 + 1 = 0", inline=True),
|
|
1347
|
+
" has no real solutions."
|
|
1348
|
+
])
|
|
1349
|
+
"""
|
|
1350
|
+
|
|
1351
|
+
def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
|
|
1352
|
+
super().__init__(add_spacing_before=True)
|
|
1353
|
+
for line in lines_or_elements:
|
|
1354
|
+
if isinstance(line, str):
|
|
1355
|
+
self.elements.append(ContentAST.Text(line))
|
|
1356
|
+
else:
|
|
1357
|
+
self.elements.append(line)
|
|
1358
|
+
|
|
1359
|
+
def render(self, output_format, **kwargs):
|
|
1360
|
+
# Add in new lines to break these up visually
|
|
1361
|
+
return "\n\n" + super().render(output_format, **kwargs) + "\n\n"
|
|
1362
|
+
|
|
1363
|
+
def render_html(self, **kwargs):
|
|
1364
|
+
return super().render_html(**kwargs) + "<br>"
|
|
1365
|
+
|
|
1366
|
+
def add_line(self, line: str):
|
|
1367
|
+
self.elements.append(ContentAST.Text(line))
|
|
1368
|
+
|
|
1369
|
+
class Table(Container):
|
|
1076
1370
|
"""
|
|
1077
1371
|
Structured data table with cross-format rendering and proper formatting.
|
|
1078
1372
|
|
|
@@ -1302,170 +1596,7 @@ class ContentAST:
|
|
|
1302
1596
|
|
|
1303
1597
|
return "\n#box(" + "\n".join(result) + "\n)"
|
|
1304
1598
|
|
|
1305
|
-
|
|
1306
|
-
class Section(Element):
|
|
1307
|
-
"""
|
|
1308
|
-
Primary container for question content - USE THIS for get_body() and get_explanation().
|
|
1309
|
-
|
|
1310
|
-
This is the most important ContentAST class for question developers.
|
|
1311
|
-
It serves as the main container for organizing question content
|
|
1312
|
-
and should be the return type for your get_body() and get_explanation() methods.
|
|
1313
|
-
|
|
1314
|
-
CRITICAL: Always use ContentAST.Section as the container for:
|
|
1315
|
-
- Question body content (return from get_body())
|
|
1316
|
-
- Question explanation/solution content (return from get_explanation())
|
|
1317
|
-
- Any grouped content that needs to render together
|
|
1318
|
-
|
|
1319
|
-
When to use:
|
|
1320
|
-
- As the root container in get_body() and get_explanation() methods
|
|
1321
|
-
- Grouping related content elements
|
|
1322
|
-
- Organizing complex question content
|
|
1323
|
-
|
|
1324
|
-
Example:
|
|
1325
|
-
def get_body(self):
|
|
1326
|
-
body = ContentAST.Section()
|
|
1327
|
-
body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
|
|
1328
|
-
|
|
1329
|
-
matrix_data = [[1, 2], [3, 4]]
|
|
1330
|
-
body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
|
|
1331
|
-
|
|
1332
|
-
body.add_element(ContentAST.Answer(answer=self.answer, label="Determinant"))
|
|
1333
|
-
return body
|
|
1334
|
-
"""
|
|
1335
|
-
|
|
1336
|
-
def render_typst(self, **kwargs):
|
|
1337
|
-
"""Render section by directly calling render on each child element."""
|
|
1338
|
-
return "".join(element.render("typst", **kwargs) for element in self.elements)
|
|
1339
|
-
|
|
1340
|
-
class Paragraph(Element):
|
|
1341
|
-
"""
|
|
1342
|
-
Text block container with proper spacing and paragraph formatting.
|
|
1343
|
-
|
|
1344
|
-
IMPORTANT: Use this for grouping text content, especially in question bodies.
|
|
1345
|
-
Automatically handles spacing between paragraphs and combines multiple
|
|
1346
|
-
lines/elements into a cohesive text block.
|
|
1347
|
-
|
|
1348
|
-
When to use:
|
|
1349
|
-
- Question instructions or problem statements
|
|
1350
|
-
- Multi-line text content
|
|
1351
|
-
- Grouping related text elements
|
|
1352
|
-
- Any text that should be visually separated as a paragraph
|
|
1353
|
-
|
|
1354
|
-
When NOT to use:
|
|
1355
|
-
- Single words or short phrases (use ContentAST.Text)
|
|
1356
|
-
- Mathematical content (use ContentAST.Equation)
|
|
1357
|
-
- Structured data (use ContentAST.Table)
|
|
1358
|
-
|
|
1359
|
-
Example:
|
|
1360
|
-
# Multi-line question text
|
|
1361
|
-
body.add_element(ContentAST.Paragraph([
|
|
1362
|
-
"Consider the following system:",
|
|
1363
|
-
"- Process A requires 4MB memory",
|
|
1364
|
-
"- Process B requires 2MB memory",
|
|
1365
|
-
"How much total memory is needed?"
|
|
1366
|
-
]))
|
|
1367
|
-
|
|
1368
|
-
# Mixed content paragraph
|
|
1369
|
-
para = ContentAST.Paragraph([
|
|
1370
|
-
"The equation ",
|
|
1371
|
-
ContentAST.Equation("x^2 + 1 = 0", inline=True),
|
|
1372
|
-
" has no real solutions."
|
|
1373
|
-
])
|
|
1374
|
-
"""
|
|
1375
|
-
|
|
1376
|
-
def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
|
|
1377
|
-
super().__init__(add_spacing_before=True)
|
|
1378
|
-
for line in lines_or_elements:
|
|
1379
|
-
if isinstance(line, str):
|
|
1380
|
-
self.elements.append(ContentAST.Text(line))
|
|
1381
|
-
else:
|
|
1382
|
-
self.elements.append(line)
|
|
1383
|
-
|
|
1384
|
-
def render(self, output_format, **kwargs):
|
|
1385
|
-
# Add in new lines to break these up visually
|
|
1386
|
-
return "\n\n" + super().render(output_format, **kwargs)
|
|
1387
|
-
|
|
1388
|
-
def add_line(self, line: str):
|
|
1389
|
-
self.elements.append(ContentAST.Text(line))
|
|
1390
|
-
|
|
1391
|
-
class Answer(Element):
|
|
1392
|
-
"""
|
|
1393
|
-
Answer input field that renders as blanks in PDF and shows answers in HTML.
|
|
1394
|
-
|
|
1395
|
-
CRITICAL: Use this for ALL answer inputs in questions.
|
|
1396
|
-
Creates appropriate input fields that work across both PDF and Canvas formats.
|
|
1397
|
-
In PDF, renders as blank lines for students to fill in.
|
|
1398
|
-
In HTML/Canvas, can display the answer for checking.
|
|
1399
|
-
|
|
1400
|
-
When to use:
|
|
1401
|
-
- Any place where students need to input an answer
|
|
1402
|
-
- Numerical answers, short text answers, etc.
|
|
1403
|
-
- Questions requiring fill-in-the-blank responses
|
|
1404
|
-
|
|
1405
|
-
Example:
|
|
1406
|
-
# Basic answer field
|
|
1407
|
-
body.add_element(ContentAST.Answer(
|
|
1408
|
-
answer=self.answer,
|
|
1409
|
-
label="Result",
|
|
1410
|
-
unit="MB"
|
|
1411
|
-
))
|
|
1412
|
-
|
|
1413
|
-
# Multiple choice or complex answers
|
|
1414
|
-
body.add_element(ContentAST.Answer(
|
|
1415
|
-
answer=[self.answer_a, self.answer_b],
|
|
1416
|
-
label="Choose the best answer"
|
|
1417
|
-
))
|
|
1418
|
-
"""
|
|
1419
|
-
|
|
1420
|
-
def __init__(self, answer: Answer = None, label: str = "", unit: str = "", blank_length=5):
|
|
1421
|
-
super().__init__()
|
|
1422
|
-
self.answer = answer
|
|
1423
|
-
self.label = label
|
|
1424
|
-
self.unit = unit
|
|
1425
|
-
self.length = blank_length
|
|
1426
|
-
|
|
1427
|
-
def render_markdown(self, **kwargs):
|
|
1428
|
-
if not isinstance(self.answer, list):
|
|
1429
|
-
key_to_display = self.answer.key
|
|
1430
|
-
else:
|
|
1431
|
-
key_to_display = self.answer[0].key
|
|
1432
|
-
return f"{self.label + (':' if len(self.label) > 0 else '')} [{key_to_display}] {self.unit}".strip()
|
|
1433
|
-
|
|
1434
|
-
def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
|
|
1435
|
-
log.debug(f"can_be_numerical: {can_be_numerical}")
|
|
1436
|
-
log.debug(f"kwargs: {kwargs}")
|
|
1437
|
-
if can_be_numerical:
|
|
1438
|
-
return f"Calculate {self.label}"
|
|
1439
|
-
if show_answers and self.answer:
|
|
1440
|
-
# Show actual answer value using formatted display string
|
|
1441
|
-
if not isinstance(self.answer, list):
|
|
1442
|
-
answer_display = self.answer.get_display_string()
|
|
1443
|
-
else:
|
|
1444
|
-
answer_display = ", ".join(a.get_display_string() for a in self.answer)
|
|
1445
|
-
|
|
1446
|
-
label_part = f"{self.label}:" if self.label else ""
|
|
1447
|
-
unit_part = f" {self.unit}" if self.unit else ""
|
|
1448
|
-
return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
|
|
1449
|
-
else:
|
|
1450
|
-
# Default behavior: show [key]
|
|
1451
|
-
return self.render_markdown(**kwargs)
|
|
1452
|
-
|
|
1453
|
-
def render_latex(self, **kwargs):
|
|
1454
|
-
return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.length}}} {self.unit}".strip()
|
|
1455
|
-
|
|
1456
|
-
def render_typst(self, **kwargs):
|
|
1457
|
-
"""Render answer blank as an underlined space in Typst."""
|
|
1458
|
-
# Use the fillline function defined in TYPST_HEADER
|
|
1459
|
-
# Width is based on self.length (in cm)
|
|
1460
|
-
blank_width = self.length * 0.75 # Convert character length to cm
|
|
1461
|
-
blank = f"#fillline(width: {blank_width}cm)"
|
|
1462
|
-
|
|
1463
|
-
label_part = f"{self.label}:" if self.label else ""
|
|
1464
|
-
unit_part = f" {self.unit}" if self.unit else ""
|
|
1465
|
-
|
|
1466
|
-
return f"{label_part} {blank}{unit_part}".strip()
|
|
1467
|
-
|
|
1468
|
-
class TableGroup(Element):
|
|
1599
|
+
class TableGroup(Container):
|
|
1469
1600
|
"""
|
|
1470
1601
|
Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
|
|
1471
1602
|
|
|
@@ -1653,7 +1784,7 @@ class ContentAST:
|
|
|
1653
1784
|
return content
|
|
1654
1785
|
|
|
1655
1786
|
## Specialized Elements
|
|
1656
|
-
class RepeatedProblemPart(
|
|
1787
|
+
class RepeatedProblemPart(Container):
|
|
1657
1788
|
"""
|
|
1658
1789
|
Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
|
|
1659
1790
|
|
|
@@ -1768,7 +1899,7 @@ class ContentAST:
|
|
|
1768
1899
|
result.append(r"\end{alignat*}")
|
|
1769
1900
|
return "\n".join(result)
|
|
1770
1901
|
|
|
1771
|
-
class OnlyLatex(
|
|
1902
|
+
class OnlyLatex(Container):
|
|
1772
1903
|
"""
|
|
1773
1904
|
Container element that only renders content in LaTeX/PDF output format.
|
|
1774
1905
|
|
|
@@ -1790,12 +1921,12 @@ class ContentAST:
|
|
|
1790
1921
|
body.add_element(latex_only)
|
|
1791
1922
|
"""
|
|
1792
1923
|
|
|
1793
|
-
def render(self, output_format, **kwargs):
|
|
1924
|
+
def render(self, output_format: ContentAST.OutputFormat, **kwargs):
|
|
1794
1925
|
if output_format != "latex":
|
|
1795
1926
|
return ""
|
|
1796
|
-
return super().render(output_format, **kwargs)
|
|
1927
|
+
return super().render(output_format=output_format, **kwargs)
|
|
1797
1928
|
|
|
1798
|
-
class OnlyHtml(
|
|
1929
|
+
class OnlyHtml(Container):
|
|
1799
1930
|
"""
|
|
1800
1931
|
Container element that only renders content in HTML/Canvas output format.
|
|
1801
1932
|
|