QuizGenerator 0.1.4__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import abc
4
+ import enum
3
5
  import re
4
6
  import textwrap
5
7
  from io import BytesIO
6
8
  from typing import List, Callable
7
9
 
10
+ import numpy
8
11
  import pypandoc
9
12
  import markdown
10
13
 
11
- from QuizGenerator.misc import log, Answer
14
+ # from QuizGenerator.misc import Answer
12
15
 
13
16
  from QuizGenerator.qrcode_generator import QuestionQRCode
14
17
  import re
15
18
 
19
+ import logging
20
+ log = logging.getLogger(__name__)
21
+
16
22
  class ContentAST:
17
23
  """
18
24
  Content Abstract Syntax Tree - The core content system for quiz generation.
@@ -45,7 +51,13 @@ class ContentAST:
45
51
  body.add_element(ContentAST.Text("\\\\begin{bmatrix} 1 & 2 \\\\\\\\ 3 & 4 \\\\end{bmatrix}"))
46
52
  """
47
53
 
48
- class Element:
54
+ class OutputFormat(enum.StrEnum):
55
+ HTML = "html"
56
+ TYPST = "typst"
57
+ LATEX = "latex"
58
+ MARKDOWN = "markdown"
59
+
60
+ class Element(abc.ABC):
49
61
  """
50
62
  Base class for all ContentAST elements providing cross-format rendering.
51
63
 
@@ -75,81 +87,92 @@ class ContentAST:
75
87
  html_output = section.render("html")
76
88
  """
77
89
  def __init__(self, elements=None, add_spacing_before=False):
78
- self.elements : List[ContentAST.Element] = [
79
- e if isinstance(e, ContentAST.Element) else ContentAST.Text(e)
80
- for e in (elements if elements else [])
81
- ]
82
- self.add_spacing_before = add_spacing_before
90
+ pass
91
+ # self.elements : List[ContentAST.Element] = [
92
+ # e if isinstance(e, ContentAST.Element) else ContentAST.Text(e)
93
+ # for e in (elements if elements else [])
94
+ # ]
95
+ # self.add_spacing_before = add_spacing_before
83
96
 
84
97
  def __str__(self):
85
98
  return self.render_markdown()
86
99
 
87
- def add_element(self, element):
88
- self.elements.append(element)
89
-
90
- def add_elements(self, elements, new_paragraph=False):
91
- if new_paragraph:
92
- self.elements.append(ContentAST.Text(""))
93
- self.elements.extend(elements)
94
-
95
- def convert_markdown(self, str_to_convert, output_format):
96
- if output_format == "html":
97
- # Fast in-process HTML conversion using Python markdown library
98
- try:
99
- # Convert markdown to HTML using fast Python library
100
- html_output = markdown.markdown(str_to_convert)
101
-
102
- # Strip surrounding <p> tags for inline content (matching old behavior)
103
- if html_output.startswith("<p>") and html_output.endswith("</p>"):
104
- html_output = html_output[3:-4]
105
-
106
- return html_output.strip()
107
- except Exception as e:
108
- log.warning(f"Markdown conversion failed: {e}. Returning original content.")
109
- return str_to_convert
110
- else:
111
- # Keep using Pandoc for LaTeX and other formats (less critical path)
112
- try:
113
- output = pypandoc.convert_text(
114
- str_to_convert,
115
- output_format,
116
- format='md',
117
- extra_args=["-M2GB", "+RTS", "-K64m", "-RTS"]
118
- )
119
- return output
120
- except RuntimeError as e:
121
- log.warning(f"Specified conversion format '{output_format}' not recognized by pypandoc. Defaulting to markdown")
122
- return None
123
-
124
- def render(self, output_format, **kwargs) -> str:
100
+ def render(self, output_format : ContentAST.OutputFormat, **kwargs) -> str:
101
+ # Render using the appropriate method, if it exists
125
102
  method_name = f"render_{output_format}"
126
103
  if hasattr(self, method_name):
127
104
  return getattr(self, method_name)(**kwargs)
128
105
 
129
106
  return self.render_markdown(**kwargs) # Fallback to markdown
130
107
 
108
+ @abc.abstractmethod
131
109
  def render_markdown(self, **kwargs):
132
- return " ".join(element.render("markdown", **kwargs) for element in self.elements)
110
+ pass
133
111
 
112
+ @abc.abstractmethod
134
113
  def render_html(self, **kwargs):
135
-
136
- html_parts = []
137
- for element in self.elements:
138
- try:
139
- html_parts.append(element.render("html", **kwargs))
140
- except AttributeError:
141
- log.error(f"That's the one: \"{element.__class__}\" \"{element}\"")
142
- exit(8)
143
-
144
- html = " ".join(html_parts)
145
- #html = " ".join(element.render("html", **kwargs) for element in self.elements)
146
- return f"{'<br>' if self.add_spacing_before else ''}{html}"
114
+ pass
147
115
 
116
+ @abc.abstractmethod
148
117
  def render_latex(self, **kwargs):
118
+ pass
119
+
120
+ @abc.abstractmethod
121
+ def render_typst(self, **kwargs):
122
+ pass
123
+
124
+ def is_mergeable(self, other: ContentAST.Element):
125
+ return False
126
+
127
+ class Container(Element):
128
+ """Elements that contain other elements. Generally are formatting of larger pieces."""
129
+ def __init__(self, elements=None, **kwargs):
130
+ super().__init__(**kwargs)
131
+ self.elements : List[ContentAST.Element] = elements if elements is not None else []
132
+
133
+ def add_element(self, element):
134
+ self.elements.append(element)
135
+
136
+ def add_elements(self, elements):
137
+ self.elements.extend(elements)
138
+
139
+ @staticmethod
140
+ def render_element(element, output_format: ContentAST.OutputFormat, **kwargs):
141
+ if isinstance(element, ContentAST.Element):
142
+ return element.render(output_format, **kwargs)
143
+ log.warning(f"Element ({element}) is not ContentAST.Element. Defaulting to forcing to a string.")
144
+ return f"{element}"
145
+
146
+ def render_markdown(self, **kwargs):
147
+ return " ".join([
148
+ self.render_element(element, output_format=ContentAST.OutputFormat.MARKDOWN, **kwargs)
149
+ for element in self.elements
150
+ ])
151
+
152
+ def render_html(self, **kwargs):
153
+ for element in self.elements:
154
+ log.debug(f"element: {element}")
155
+ return " ".join([
156
+ self.render_element(element, output_format=ContentAST.OutputFormat.HTML, **kwargs)
157
+ for element in self.elements
158
+ ])
159
+
160
+ def render_latex(self, **kwargs):
161
+ return "".join([
162
+ self.render_element(element, output_format=ContentAST.OutputFormat.LATEX, **kwargs)
163
+ for element in self.elements
164
+ ])
165
+
149
166
  latex = "".join(element.render("latex", **kwargs) for element in self.elements)
150
167
  return f"{'\n\n\\vspace{0.5cm}' if self.add_spacing_before else ''}{latex}"
151
-
168
+
152
169
  def render_typst(self, **kwargs):
170
+
171
+ return " ".join([
172
+ self.render_element(element, output_format=ContentAST.OutputFormat.TYPST, **kwargs)
173
+ for element in self.elements
174
+ ])
175
+
153
176
  """
154
177
  Default Typst rendering using markdown → typst conversion via pandoc.
155
178
 
@@ -160,21 +183,67 @@ class ContentAST:
160
183
  """
161
184
  # Render to markdown first
162
185
  markdown_content = self.render_markdown(**kwargs)
163
-
186
+
164
187
  # Convert markdown to Typst via pandoc
165
188
  typst_content = self.convert_markdown(markdown_content, 'typst')
166
-
189
+
167
190
  # Add spacing if needed (Typst equivalent of \vspace)
168
191
  if self.add_spacing_before:
169
192
  return f"\n{typst_content}"
170
-
193
+
171
194
  return typst_content if typst_content else markdown_content
172
195
 
173
- def is_mergeable(self, other: ContentAST.Element):
174
- return False
196
+ class Leaf(Element):
197
+ """Elements that are just themselves."""
198
+ def __init__(self, content : str, **kwargs):
199
+ super().__init__(**kwargs)
200
+ self.content = content
201
+
202
+ @staticmethod
203
+ def convert_markdown(str_to_convert, output_format : ContentAST.OutputFormat):
204
+ try:
205
+ match output_format:
206
+
207
+ case ContentAST.OutputFormat.MARKDOWN:
208
+ return str_to_convert
209
+
210
+ case ContentAST.OutputFormat.HTML:
211
+ html_output = markdown.markdown(str_to_convert)
212
+
213
+ # Strip surrounding <p> tags so we can control paragraphs
214
+ if html_output.startswith("<p>") and html_output.endswith("</p>"):
215
+ html_output = html_output[3:-4]
216
+
217
+ return html_output.strip()
218
+
219
+ case _:
220
+ output = pypandoc.convert_text(
221
+ str_to_convert,
222
+ output_format,
223
+ format='md',
224
+ extra_args=["-M2GB", "+RTS", "-K64m", "-RTS"]
225
+ )
226
+ return output
227
+ except Exception as e:
228
+ log.warning(f"Specified conversion failed. Defaulting to markdown")
229
+ log.warning(e)
230
+
231
+ return str(str_to_convert)
232
+
233
+ def render_markdown(self, **kwargs):
234
+ return self.convert_markdown(self.content, ContentAST.OutputFormat.MARKDOWN)
235
+
236
+ def render_html(self, **kwargs):
237
+ return self.convert_markdown(self.content, ContentAST.OutputFormat.HTML)
238
+
239
+ def render_latex(self, **kwargs):
240
+ return self.convert_markdown(self.content, ContentAST.OutputFormat.LATEX)
241
+
242
+ def render_typst(self, **kwargs):
243
+ return self.convert_markdown(self.content, ContentAST.OutputFormat.TYPST) #.replace("#", r"\#")
175
244
 
176
- ## Big containers
177
- class Document(Element):
245
+ ## Top-ish Level containers
246
+ class Document(Container):
178
247
  """
179
248
  Root document container for complete quiz documents with proper headers and structure.
180
249
 
@@ -379,7 +448,7 @@ class ContentAST:
379
448
 
380
449
  """)
381
450
 
382
- latex += "\n".join(element.render("latex", **kwargs) for element in self.elements)
451
+ latex += "\n".join(element.render(ContentAST.OutputFormat.LATEX, **kwargs) for element in self.elements)
383
452
 
384
453
  latex += r"\end{document}"
385
454
 
@@ -399,11 +468,11 @@ class ContentAST:
399
468
  typst += f"#v(0.5cm)\n"
400
469
 
401
470
  # Render all elements
402
- typst += "".join(element.render("typst", **kwargs) for element in self.elements)
471
+ typst += "".join(element.render(ContentAST.OutputFormat.TYPST, **kwargs) for element in self.elements)
403
472
 
404
473
  return typst
405
474
 
406
- class Question(Element):
475
+ class Question(Container):
407
476
  """
408
477
  Complete question container with body, explanation, and metadata.
409
478
 
@@ -533,7 +602,7 @@ class ContentAST:
533
602
  def render_typst(self, **kwargs):
534
603
  """Render question in Typst format with proper formatting"""
535
604
  # Render question body
536
- content = self.body.render("typst", **kwargs)
605
+ content = self.body.render(ContentAST.OutputFormat.TYPST, **kwargs)
537
606
 
538
607
  # Generate QR code if question number is available
539
608
  qr_param = ""
@@ -575,9 +644,40 @@ class ContentAST:
575
644
  {qr_param}
576
645
  )[
577
646
  """) + content + "\n]\n\n"
578
-
647
+
648
+ class Section(Container):
649
+ """
650
+ Primary container for question content - USE THIS for get_body() and get_explanation().
651
+
652
+ This is the most important ContentAST class for question developers.
653
+ It serves as the main container for organizing question content
654
+ and should be the return type for your get_body() and get_explanation() methods.
655
+
656
+ CRITICAL: Always use ContentAST.Section as the container for:
657
+ - Question body content (return from get_body())
658
+ - Question explanation/solution content (return from get_explanation())
659
+ - Any grouped content that needs to render together
660
+
661
+ When to use:
662
+ - As the root container in get_body() and get_explanation() methods
663
+ - Grouping related content elements
664
+ - Organizing complex question content
665
+
666
+ Example:
667
+ def get_body(self):
668
+ body = ContentAST.Section()
669
+ body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
670
+
671
+ matrix_data = [[1, 2], [3, 4]]
672
+ body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
673
+
674
+ body.add_element(ContentAST.Answer(answer=self.answer, label="Determinant"))
675
+ return body
676
+ """
677
+ pass
678
+
579
679
  # Individual elements
580
- class Text(Element):
680
+ class Text(Leaf):
581
681
  """
582
682
  Basic text content with automatic format conversion and selective visibility.
583
683
 
@@ -606,8 +706,7 @@ class ContentAST:
606
706
  web_note = ContentAST.Text("Click submit", hide_from_latex=True)
607
707
  """
608
708
  def __init__(self, content : str, *, hide_from_latex=False, hide_from_html=False, emphasis=False):
609
- super().__init__()
610
- self.content = content
709
+ super().__init__(content)
611
710
  self.hide_from_latex = hide_from_latex
612
711
  self.hide_from_html = hide_from_html
613
712
  self.emphasis = emphasis
@@ -618,26 +717,19 @@ class ContentAST:
618
717
  def render_html(self, **kwargs):
619
718
  if self.hide_from_html:
620
719
  return ""
621
- # If the super function returns None then we just return content as is
622
- conversion_results = super().convert_markdown(self.content.replace("#", r"\#"), "html").strip()
623
- if conversion_results is not None:
624
- if conversion_results.startswith("<p>") and conversion_results.endswith("</p>"):
625
- conversion_results = conversion_results[3:-4]
626
- return conversion_results or self.content
627
-
720
+ return self.convert_markdown(self.content,ContentAST.OutputFormat.HTML)
721
+
628
722
  def render_latex(self, **kwargs):
629
723
  if self.hide_from_latex:
630
724
  return ""
631
- # Escape # to prevent markdown header conversion in LaTeX
632
- content = super().convert_markdown(self.content.replace("#", r"\#"), "latex") or self.content
633
- return content
725
+ return self.convert_markdown(self.content.replace("#", r"\#"), ContentAST.OutputFormat.LATEX)
634
726
 
635
727
  def render_typst(self, **kwargs):
636
728
  """Render text to Typst, escaping special characters."""
637
- # Hide HTML-only text from Typst (since Typst generates PDFs like LaTeX)
638
729
  if self.hide_from_latex:
639
730
  return ""
640
731
 
732
+ # This is for when we are passing in a code block via a FromText question
641
733
  content = re.sub(
642
734
  r"```\s*(.*)\s*```",
643
735
  r"""
@@ -653,12 +745,9 @@ class ContentAST:
653
745
 
654
746
  # In Typst, # starts code/function calls, so we need to escape it
655
747
  content = content.replace("# ", r"\# ")
656
- # content = self.content
657
-
658
- # Apply emphasis if needed
748
+
659
749
  if self.emphasis:
660
750
  content = f"*{content}*"
661
-
662
751
  return content
663
752
 
664
753
  def is_mergeable(self, other: ContentAST.Element):
@@ -706,31 +795,25 @@ class ContentAST:
706
795
  self.make_normal = kwargs.get("make_normal", False)
707
796
 
708
797
  def render_markdown(self, **kwargs):
709
- content = "```\n" + self.content + "\n```"
798
+ content = "```" + self.content.rstrip() + "\n```"
710
799
  return content
711
800
 
712
801
  def render_html(self, **kwargs):
713
-
714
- return super().convert_markdown(textwrap.indent(self.content, "\t"), "html") or self.content
802
+ return self.convert_markdown(textwrap.indent(self.content, "\t"), ContentAST.OutputFormat.HTML)
715
803
 
716
804
  def render_latex(self, **kwargs):
717
- content = super().convert_markdown(self.render_markdown(), "latex") or self.content
718
- if self.make_normal:
719
- content = (
720
- r"{\normal "
721
- + content +
722
- r"}"
723
- )
724
- return content
805
+ return self.convert_markdown(self.render_markdown(), ContentAST.OutputFormat.LATEX)
725
806
 
726
807
  def render_typst(self, **kwargs):
727
808
  """Render code block in Typst with smaller monospace font."""
728
809
  # Use raw block with 11pt font size
729
810
  # Escape backticks in the content
730
811
  escaped_content = self.content.replace("`", r"\`")
731
- return f"#box[#text(size: 8pt)[```\n{escaped_content}\n```]]"
812
+
813
+ # Try to reduce individual pathway to ensure consistency
814
+ return ContentAST.Text(f"```\n{escaped_content.rstrip()}\n```").render_typst()
732
815
 
733
- class Equation(Element):
816
+ class Equation(Leaf):
734
817
  """
735
818
  Mathematical equation renderer with LaTeX input and cross-format output.
736
819
 
@@ -759,7 +842,7 @@ class ContentAST:
759
842
  body.add_element(ContentAST.Equation(r"\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}"))
760
843
  """
761
844
  def __init__(self, latex, inline=False):
762
- super().__init__()
845
+ super().__init__("[equation]")
763
846
  self.latex = latex
764
847
  self.inline = inline
765
848
 
@@ -872,7 +955,7 @@ class ContentAST:
872
955
 
873
956
  return cls('\n'.join(equation_lines))
874
957
 
875
- class Matrix(Element):
958
+ class Matrix(Leaf):
876
959
  """
877
960
  Mathematical matrix renderer for consistent cross-format display.
878
961
 
@@ -902,21 +985,36 @@ class ContentAST:
902
985
  - "B": curly braces {matrix}
903
986
  - "V": double vertical bars ||matrix|| - for norms
904
987
  """
905
- def __init__(self, data, bracket_type="p", inline=False):
988
+ def __init__(self, data, *, bracket_type="p", inline=False, name=None):
906
989
  """
907
990
  Creates a matrix element that renders consistently across output formats.
908
991
 
909
992
  Args:
910
- data: Matrix data as List[List[numbers/strings]]
911
- For vectors: [[v1], [v2], [v3]] (column vector)
912
- For matrices: [[a, b], [c, d]]
993
+ data: Matrix data as List[List[numbers/strings]] or numpy ndarray (1D or 2D)
994
+ For vectors: [[v1], [v2], [v3]] (column vector) or np.array([v1, v2, v3])
995
+ For matrices: [[a, b], [c, d]] or np.array([[a, b], [c, d]])
913
996
  bracket_type: Bracket style - "b" for [], "p" for (), "v" for |, etc.
914
997
  inline: Whether to use inline (smaller) matrix formatting
915
998
  """
916
- super().__init__()
917
- self.data = data
999
+ super().__init__("[matrix]")
1000
+
1001
+ # Convert numpy ndarray to list format if needed
1002
+ import numpy as np
1003
+ if isinstance(data, np.ndarray):
1004
+ if data.ndim == 1:
1005
+ # 1D array: convert to column vector [[v1], [v2], [v3]]
1006
+ self.data = [[val] for val in data]
1007
+ elif data.ndim == 2:
1008
+ # 2D array: convert to list of lists
1009
+ self.data = data.tolist()
1010
+ else:
1011
+ raise ValueError(f"Matrix only supports 1D or 2D arrays, got {data.ndim}D")
1012
+ else:
1013
+ self.data = data
1014
+
918
1015
  self.bracket_type = bracket_type
919
1016
  self.inline = inline
1017
+ self.name = name
920
1018
 
921
1019
  @staticmethod
922
1020
  def to_latex(data, bracket_type="p"):
@@ -955,14 +1053,19 @@ class ContentAST:
955
1053
  def render_html(self, **kwargs):
956
1054
  matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
957
1055
  rows = []
958
- for row in self.data:
1056
+ if isinstance(self.data, numpy.ndarray):
1057
+ data = self.data.tolist()
1058
+ else:
1059
+ data = self.data
1060
+ for row in data:
959
1061
  rows.append(" & ".join(str(cell) for cell in row))
960
1062
  matrix_content = r" \\ ".join(rows)
961
1063
 
962
1064
  if self.inline:
963
1065
  return f"<span class='math'>$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$</span>"
964
1066
  else:
965
- return f"<div class='math'>$$\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}$$</div>"
1067
+ name_str = f"\\text{{{self.name}}} = " if self.name else ""
1068
+ return f"<div class='math'>$${name_str}\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}$$</div>"
966
1069
 
967
1070
  def render_latex(self, **kwargs):
968
1071
  matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
@@ -976,7 +1079,41 @@ class ContentAST:
976
1079
  else:
977
1080
  return f"\\[\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\]"
978
1081
 
979
- class Picture(Element):
1082
+ def render_typst(self, **kwargs):
1083
+ """Render matrix in Typst format using mat() and vec() functions."""
1084
+ # Build matrix content with semicolons separating rows
1085
+ rows = []
1086
+ for row in self.data:
1087
+ rows.append(", ".join(str(cell) for cell in row))
1088
+
1089
+ # Check if it's a vector (single column)
1090
+ is_vector = all(len(row) == 1 for row in self.data)
1091
+
1092
+ if is_vector:
1093
+ # Use vec() for vectors
1094
+ matrix_content = ", ".join(str(row[0]) for row in self.data)
1095
+ result = f"vec({matrix_content})"
1096
+ else:
1097
+ # Use mat() for matrices with semicolons separating rows
1098
+ matrix_content = "; ".join(rows)
1099
+ result = f"mat({matrix_content})"
1100
+
1101
+ # Add bracket delimiters if needed
1102
+ if self.bracket_type == "b": # square brackets
1103
+ result = f"mat(delim: \"[\", {matrix_content})" if not is_vector else f"vec(delim: \"[\", {matrix_content})"
1104
+ elif self.bracket_type == "v": # vertical bars (determinant)
1105
+ result = f"mat(delim: \"|\", {matrix_content})"
1106
+ elif self.bracket_type == "B": # curly braces
1107
+ result = f"mat(delim: \"{{\", {matrix_content})"
1108
+ # "p" (parentheses) is the default, no need to specify
1109
+
1110
+ # Wrap in math mode
1111
+ if self.inline:
1112
+ return f"${result}$"
1113
+ else:
1114
+ return f"$ {result} $"
1115
+
1116
+ class Picture(Leaf):
980
1117
  """
981
1118
  Image/diagram container with proper sizing and captioning.
982
1119
 
@@ -1008,7 +1145,7 @@ class ContentAST:
1008
1145
  body.add_element(picture)
1009
1146
  """
1010
1147
  def __init__(self, img_data, caption=None, width=None):
1011
- super().__init__()
1148
+ super().__init__("[picture]")
1012
1149
  self.img_data = img_data
1013
1150
  self.caption = caption
1014
1151
  self.width = width
@@ -1071,8 +1208,165 @@ class ContentAST:
1071
1208
 
1072
1209
  result.append("\\end{figure}")
1073
1210
  return "\n".join(result)
1211
+
1212
+ def render_typst(self, **kwargs):
1213
+ self._ensure_image_saved()
1214
+
1215
+ # Build the image function call
1216
+ img_params = []
1217
+ if self.width:
1218
+ img_params.append(f'width: {self.width}')
1219
+
1220
+ params_str = ', '.join(img_params) if img_params else ''
1221
+
1222
+ # Use Typst's figure and image functions
1223
+ result = []
1224
+ result.append("#figure(")
1225
+ result.append(f' image("{self.path}"{", " + params_str if params_str else ""}),')
1226
+
1227
+ if self.caption:
1228
+ result.append(f' caption: [{self.caption}]')
1229
+
1230
+ result.append(")")
1231
+
1232
+ return "\n".join(result)
1233
+
1234
+ class Answer(Leaf):
1235
+ """
1236
+ Answer input field that renders as blanks in PDF and shows answers in HTML.
1237
+
1238
+ CRITICAL: Use this for ALL answer inputs in questions.
1239
+ Creates appropriate input fields that work across both PDF and Canvas formats.
1240
+ In PDF, renders as blank lines for students to fill in.
1241
+ In HTML/Canvas, can display the answer for checking.
1242
+
1243
+ When to use:
1244
+ - Any place where students need to input an answer
1245
+ - Numerical answers, short text answers, etc.
1246
+ - Questions requiring fill-in-the-blank responses
1247
+
1248
+ Example:
1249
+ # Basic answer field
1250
+ body.add_element(ContentAST.Answer(
1251
+ answer=self.answer,
1252
+ label="Result",
1253
+ unit="MB"
1254
+ ))
1255
+
1256
+ # Multiple choice or complex answers
1257
+ body.add_element(ContentAST.Answer(
1258
+ answer=[self.answer_a, self.answer_b],
1259
+ label="Choose the best answer"
1260
+ ))
1261
+ """
1262
+
1263
+ def __init__(self, answer, label: str = "", unit: str = "", blank_length=5):
1264
+ super().__init__(label)
1265
+ self.answer = answer
1266
+ self.label = label
1267
+ self.unit = unit
1268
+ self.length = blank_length
1269
+
1270
+ def render_markdown(self, **kwargs):
1271
+ if not isinstance(self.answer, list):
1272
+ key_to_display = self.answer.key
1273
+ else:
1274
+ key_to_display = self.answer[0].key
1275
+ return f"{self.label + (':' if len(self.label) > 0 else '')} [{key_to_display}] {self.unit}".strip()
1276
+
1277
+ def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
1278
+ if can_be_numerical:
1279
+ return f"Calculate {self.label}"
1280
+ if show_answers and self.answer:
1281
+ # Show actual answer value using formatted display string
1282
+ if not isinstance(self.answer, list):
1283
+ answer_display = self.answer.get_display_string()
1284
+ else:
1285
+ answer_display = ", ".join(a.get_display_string() for a in self.answer)
1286
+
1287
+ label_part = f"{self.label}:" if self.label else ""
1288
+ unit_part = f" {self.unit}" if self.unit else ""
1289
+ return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
1290
+ else:
1291
+ # Default behavior: show [key]
1292
+ return self.render_markdown(**kwargs)
1293
+
1294
+ def render_latex(self, **kwargs):
1295
+ return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.length}}} {self.unit}".strip()
1296
+
1297
+ def render_typst(self, **kwargs):
1298
+ """Render answer blank as an underlined space in Typst."""
1299
+ # Use the fillline function defined in TYPST_HEADER
1300
+ # Width is based on self.length (in cm)
1301
+ blank_width = self.length * 0.75 # Convert character length to cm
1302
+ blank = f"#fillline(width: {blank_width}cm)"
1303
+
1304
+ label_part = f"{self.label}:" if self.label else ""
1305
+ unit_part = f" {self.unit}" if self.unit else ""
1306
+
1307
+ return f"{label_part} {blank}{unit_part}".strip()
1074
1308
 
1075
- class Table(Element):
1309
+ class LineBreak(Text):
1310
+ def __init__(self, *args, **kwargs):
1311
+ super().__init__("\n\n")
1312
+
1313
+ ## Containers
1314
+
1315
+ class Paragraph(Container):
1316
+ """
1317
+ Text block container with proper spacing and paragraph formatting.
1318
+
1319
+ IMPORTANT: Use this for grouping text content, especially in question bodies.
1320
+ Automatically handles spacing between paragraphs and combines multiple
1321
+ lines/elements into a cohesive text block.
1322
+
1323
+ When to use:
1324
+ - Question instructions or problem statements
1325
+ - Multi-line text content
1326
+ - Grouping related text elements
1327
+ - Any text that should be visually separated as a paragraph
1328
+
1329
+ When NOT to use:
1330
+ - Single words or short phrases (use ContentAST.Text)
1331
+ - Mathematical content (use ContentAST.Equation)
1332
+ - Structured data (use ContentAST.Table)
1333
+
1334
+ Example:
1335
+ # Multi-line question text
1336
+ body.add_element(ContentAST.Paragraph([
1337
+ "Consider the following system:",
1338
+ "- Process A requires 4MB memory",
1339
+ "- Process B requires 2MB memory",
1340
+ "How much total memory is needed?"
1341
+ ]))
1342
+
1343
+ # Mixed content paragraph
1344
+ para = ContentAST.Paragraph([
1345
+ "The equation ",
1346
+ ContentAST.Equation("x^2 + 1 = 0", inline=True),
1347
+ " has no real solutions."
1348
+ ])
1349
+ """
1350
+
1351
+ def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
1352
+ super().__init__(add_spacing_before=True)
1353
+ for line in lines_or_elements:
1354
+ if isinstance(line, str):
1355
+ self.elements.append(ContentAST.Text(line))
1356
+ else:
1357
+ self.elements.append(line)
1358
+
1359
+ def render(self, output_format, **kwargs):
1360
+ # Add in new lines to break these up visually
1361
+ return "\n\n" + super().render(output_format, **kwargs) + "\n\n"
1362
+
1363
+ def render_html(self, **kwargs):
1364
+ return super().render_html(**kwargs) + "<br>"
1365
+
1366
+ def add_line(self, line: str):
1367
+ self.elements.append(ContentAST.Text(line))
1368
+
1369
+ class Table(Container):
1076
1370
  """
1077
1371
  Structured data table with cross-format rendering and proper formatting.
1078
1372
 
@@ -1302,170 +1596,7 @@ class ContentAST:
1302
1596
 
1303
1597
  return "\n#box(" + "\n".join(result) + "\n)"
1304
1598
 
1305
- ## Containers
1306
- class Section(Element):
1307
- """
1308
- Primary container for question content - USE THIS for get_body() and get_explanation().
1309
-
1310
- This is the most important ContentAST class for question developers.
1311
- It serves as the main container for organizing question content
1312
- and should be the return type for your get_body() and get_explanation() methods.
1313
-
1314
- CRITICAL: Always use ContentAST.Section as the container for:
1315
- - Question body content (return from get_body())
1316
- - Question explanation/solution content (return from get_explanation())
1317
- - Any grouped content that needs to render together
1318
-
1319
- When to use:
1320
- - As the root container in get_body() and get_explanation() methods
1321
- - Grouping related content elements
1322
- - Organizing complex question content
1323
-
1324
- Example:
1325
- def get_body(self):
1326
- body = ContentAST.Section()
1327
- body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
1328
-
1329
- matrix_data = [[1, 2], [3, 4]]
1330
- body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
1331
-
1332
- body.add_element(ContentAST.Answer(answer=self.answer, label="Determinant"))
1333
- return body
1334
- """
1335
-
1336
- def render_typst(self, **kwargs):
1337
- """Render section by directly calling render on each child element."""
1338
- return "".join(element.render("typst", **kwargs) for element in self.elements)
1339
-
1340
- class Paragraph(Element):
1341
- """
1342
- Text block container with proper spacing and paragraph formatting.
1343
-
1344
- IMPORTANT: Use this for grouping text content, especially in question bodies.
1345
- Automatically handles spacing between paragraphs and combines multiple
1346
- lines/elements into a cohesive text block.
1347
-
1348
- When to use:
1349
- - Question instructions or problem statements
1350
- - Multi-line text content
1351
- - Grouping related text elements
1352
- - Any text that should be visually separated as a paragraph
1353
-
1354
- When NOT to use:
1355
- - Single words or short phrases (use ContentAST.Text)
1356
- - Mathematical content (use ContentAST.Equation)
1357
- - Structured data (use ContentAST.Table)
1358
-
1359
- Example:
1360
- # Multi-line question text
1361
- body.add_element(ContentAST.Paragraph([
1362
- "Consider the following system:",
1363
- "- Process A requires 4MB memory",
1364
- "- Process B requires 2MB memory",
1365
- "How much total memory is needed?"
1366
- ]))
1367
-
1368
- # Mixed content paragraph
1369
- para = ContentAST.Paragraph([
1370
- "The equation ",
1371
- ContentAST.Equation("x^2 + 1 = 0", inline=True),
1372
- " has no real solutions."
1373
- ])
1374
- """
1375
-
1376
- def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
1377
- super().__init__(add_spacing_before=True)
1378
- for line in lines_or_elements:
1379
- if isinstance(line, str):
1380
- self.elements.append(ContentAST.Text(line))
1381
- else:
1382
- self.elements.append(line)
1383
-
1384
- def render(self, output_format, **kwargs):
1385
- # Add in new lines to break these up visually
1386
- return "\n\n" + super().render(output_format, **kwargs)
1387
-
1388
- def add_line(self, line: str):
1389
- self.elements.append(ContentAST.Text(line))
1390
-
1391
- class Answer(Element):
1392
- """
1393
- Answer input field that renders as blanks in PDF and shows answers in HTML.
1394
-
1395
- CRITICAL: Use this for ALL answer inputs in questions.
1396
- Creates appropriate input fields that work across both PDF and Canvas formats.
1397
- In PDF, renders as blank lines for students to fill in.
1398
- In HTML/Canvas, can display the answer for checking.
1399
-
1400
- When to use:
1401
- - Any place where students need to input an answer
1402
- - Numerical answers, short text answers, etc.
1403
- - Questions requiring fill-in-the-blank responses
1404
-
1405
- Example:
1406
- # Basic answer field
1407
- body.add_element(ContentAST.Answer(
1408
- answer=self.answer,
1409
- label="Result",
1410
- unit="MB"
1411
- ))
1412
-
1413
- # Multiple choice or complex answers
1414
- body.add_element(ContentAST.Answer(
1415
- answer=[self.answer_a, self.answer_b],
1416
- label="Choose the best answer"
1417
- ))
1418
- """
1419
-
1420
- def __init__(self, answer: Answer = None, label: str = "", unit: str = "", blank_length=5):
1421
- super().__init__()
1422
- self.answer = answer
1423
- self.label = label
1424
- self.unit = unit
1425
- self.length = blank_length
1426
-
1427
- def render_markdown(self, **kwargs):
1428
- if not isinstance(self.answer, list):
1429
- key_to_display = self.answer.key
1430
- else:
1431
- key_to_display = self.answer[0].key
1432
- return f"{self.label + (':' if len(self.label) > 0 else '')} [{key_to_display}] {self.unit}".strip()
1433
-
1434
- def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
1435
- log.debug(f"can_be_numerical: {can_be_numerical}")
1436
- log.debug(f"kwargs: {kwargs}")
1437
- if can_be_numerical:
1438
- return f"Calculate {self.label}"
1439
- if show_answers and self.answer:
1440
- # Show actual answer value using formatted display string
1441
- if not isinstance(self.answer, list):
1442
- answer_display = self.answer.get_display_string()
1443
- else:
1444
- answer_display = ", ".join(a.get_display_string() for a in self.answer)
1445
-
1446
- label_part = f"{self.label}:" if self.label else ""
1447
- unit_part = f" {self.unit}" if self.unit else ""
1448
- return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
1449
- else:
1450
- # Default behavior: show [key]
1451
- return self.render_markdown(**kwargs)
1452
-
1453
- def render_latex(self, **kwargs):
1454
- return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.length}}} {self.unit}".strip()
1455
-
1456
- def render_typst(self, **kwargs):
1457
- """Render answer blank as an underlined space in Typst."""
1458
- # Use the fillline function defined in TYPST_HEADER
1459
- # Width is based on self.length (in cm)
1460
- blank_width = self.length * 0.75 # Convert character length to cm
1461
- blank = f"#fillline(width: {blank_width}cm)"
1462
-
1463
- label_part = f"{self.label}:" if self.label else ""
1464
- unit_part = f" {self.unit}" if self.unit else ""
1465
-
1466
- return f"{label_part} {blank}{unit_part}".strip()
1467
-
1468
- class TableGroup(Element):
1599
+ class TableGroup(Container):
1469
1600
  """
1470
1601
  Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
1471
1602
 
@@ -1653,7 +1784,7 @@ class ContentAST:
1653
1784
  return content
1654
1785
 
1655
1786
  ## Specialized Elements
1656
- class RepeatedProblemPart(Element):
1787
+ class RepeatedProblemPart(Container):
1657
1788
  """
1658
1789
  Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
1659
1790
 
@@ -1768,7 +1899,7 @@ class ContentAST:
1768
1899
  result.append(r"\end{alignat*}")
1769
1900
  return "\n".join(result)
1770
1901
 
1771
- class OnlyLatex(Element):
1902
+ class OnlyLatex(Container):
1772
1903
  """
1773
1904
  Container element that only renders content in LaTeX/PDF output format.
1774
1905
 
@@ -1790,12 +1921,12 @@ class ContentAST:
1790
1921
  body.add_element(latex_only)
1791
1922
  """
1792
1923
 
1793
- def render(self, output_format, **kwargs):
1924
+ def render(self, output_format: ContentAST.OutputFormat, **kwargs):
1794
1925
  if output_format != "latex":
1795
1926
  return ""
1796
- return super().render(output_format, **kwargs)
1927
+ return super().render(output_format=output_format, **kwargs)
1797
1928
 
1798
- class OnlyHtml(Element):
1929
+ class OnlyHtml(Container):
1799
1930
  """
1800
1931
  Container element that only renders content in HTML/Canvas output format.
1801
1932