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