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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,78 +87,92 @@ class ContentAST:
75
87
  html_output = section.render("html")
76
88
  """
77
89
  def __init__(self, elements=None, add_spacing_before=False):
78
- self.elements : List[ContentAST.Element] = elements or []
79
- 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
80
96
 
81
97
  def __str__(self):
82
98
  return self.render_markdown()
83
99
 
84
- def add_element(self, element):
85
- self.elements.append(element)
86
-
87
- def add_elements(self, elements, new_paragraph=False):
88
- if new_paragraph:
89
- self.elements.append(ContentAST.Text(""))
90
- self.elements.extend(elements)
91
-
92
- def convert_markdown(self, str_to_convert, output_format):
93
- if output_format == "html":
94
- # Fast in-process HTML conversion using Python markdown library
95
- try:
96
- # Convert markdown to HTML using fast Python library
97
- html_output = markdown.markdown(str_to_convert)
98
-
99
- # Strip surrounding <p> tags for inline content (matching old behavior)
100
- if html_output.startswith("<p>") and html_output.endswith("</p>"):
101
- html_output = html_output[3:-4]
102
-
103
- return html_output.strip()
104
- except Exception as e:
105
- log.warning(f"Markdown conversion failed: {e}. Returning original content.")
106
- return str_to_convert
107
- else:
108
- # Keep using Pandoc for LaTeX and other formats (less critical path)
109
- try:
110
- output = pypandoc.convert_text(
111
- str_to_convert,
112
- output_format,
113
- format='md',
114
- extra_args=["-M2GB", "+RTS", "-K64m", "-RTS"]
115
- )
116
- return output
117
- except RuntimeError as e:
118
- log.warning(f"Specified conversion format '{output_format}' not recognized by pypandoc. Defaulting to markdown")
119
- return None
120
-
121
- def render(self, output_format, **kwargs) -> str:
100
+ def render(self, output_format : ContentAST.OutputFormat, **kwargs) -> str:
101
+ # Render using the appropriate method, if it exists
122
102
  method_name = f"render_{output_format}"
123
103
  if hasattr(self, method_name):
124
104
  return getattr(self, method_name)(**kwargs)
125
105
 
126
106
  return self.render_markdown(**kwargs) # Fallback to markdown
127
107
 
108
+ @abc.abstractmethod
128
109
  def render_markdown(self, **kwargs):
129
- return " ".join(element.render("markdown", **kwargs) for element in self.elements)
110
+ pass
130
111
 
112
+ @abc.abstractmethod
131
113
  def render_html(self, **kwargs):
132
-
133
- html_parts = []
134
- for element in self.elements:
135
- try:
136
- html_parts.append(element.render("html", **kwargs))
137
- except AttributeError:
138
- log.error(f"That's the one: \"{element.__class__}\" \"{element}\"")
139
- exit(8)
140
-
141
- html = " ".join(html_parts)
142
- #html = " ".join(element.render("html", **kwargs) for element in self.elements)
143
- return f"{'<br>' if self.add_spacing_before else ''}{html}"
114
+ pass
115
+
116
+ @abc.abstractmethod
117
+ def render_latex(self, **kwargs):
118
+ pass
119
+
120
+ @abc.abstractmethod
121
+ def render_typst(self, **kwargs):
122
+ pass
123
+
124
+ def is_mergeable(self, other: ContentAST.Element):
125
+ return False
126
+
127
+ class Container(Element):
128
+ """Elements that contain other elements. Generally are formatting of larger pieces."""
129
+ def __init__(self, elements=None, **kwargs):
130
+ super().__init__(**kwargs)
131
+ self.elements : List[ContentAST.Element] = elements if elements is not None else []
144
132
 
133
+ def add_element(self, element):
134
+ self.elements.append(element)
135
+
136
+ def add_elements(self, elements):
137
+ self.elements.extend(elements)
138
+
139
+ @staticmethod
140
+ def render_element(element, output_format: ContentAST.OutputFormat, **kwargs):
141
+ if isinstance(element, ContentAST.Element):
142
+ return element.render(output_format, **kwargs)
143
+ log.warning(f"Element ({element}) is not ContentAST.Element. Defaulting to forcing to a string.")
144
+ return f"{element}"
145
+
146
+ def render_markdown(self, **kwargs):
147
+ return " ".join([
148
+ self.render_element(element, output_format=ContentAST.OutputFormat.MARKDOWN, **kwargs)
149
+ for element in self.elements
150
+ ])
151
+
152
+ def render_html(self, **kwargs):
153
+ for element in self.elements:
154
+ log.debug(f"element: {element}")
155
+ return " ".join([
156
+ self.render_element(element, output_format=ContentAST.OutputFormat.HTML, **kwargs)
157
+ for element in self.elements
158
+ ])
159
+
145
160
  def render_latex(self, **kwargs):
161
+ return "".join([
162
+ self.render_element(element, output_format=ContentAST.OutputFormat.LATEX, **kwargs)
163
+ for element in self.elements
164
+ ])
165
+
146
166
  latex = "".join(element.render("latex", **kwargs) for element in self.elements)
147
167
  return f"{'\n\n\\vspace{0.5cm}' if self.add_spacing_before else ''}{latex}"
148
-
168
+
149
169
  def render_typst(self, **kwargs):
170
+
171
+ return " ".join([
172
+ self.render_element(element, output_format=ContentAST.OutputFormat.TYPST, **kwargs)
173
+ for element in self.elements
174
+ ])
175
+
150
176
  """
151
177
  Default Typst rendering using markdown → typst conversion via pandoc.
152
178
 
@@ -157,21 +183,67 @@ class ContentAST:
157
183
  """
158
184
  # Render to markdown first
159
185
  markdown_content = self.render_markdown(**kwargs)
160
-
186
+
161
187
  # Convert markdown to Typst via pandoc
162
188
  typst_content = self.convert_markdown(markdown_content, 'typst')
163
-
189
+
164
190
  # Add spacing if needed (Typst equivalent of \vspace)
165
191
  if self.add_spacing_before:
166
192
  return f"\n{typst_content}"
167
-
193
+
168
194
  return typst_content if typst_content else markdown_content
169
195
 
170
- def is_mergeable(self, other: ContentAST.Element):
171
- 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"\#")
172
244
 
173
- ## Big containers
174
- class Document(Element):
245
+ ## Top-ish Level containers
246
+ class Document(Container):
175
247
  """
176
248
  Root document container for complete quiz documents with proper headers and structure.
177
249
 
@@ -376,7 +448,7 @@ class ContentAST:
376
448
 
377
449
  """)
378
450
 
379
- latex += "\n".join(element.render("latex", **kwargs) for element in self.elements)
451
+ latex += "\n".join(element.render(ContentAST.OutputFormat.LATEX, **kwargs) for element in self.elements)
380
452
 
381
453
  latex += r"\end{document}"
382
454
 
@@ -396,11 +468,11 @@ class ContentAST:
396
468
  typst += f"#v(0.5cm)\n"
397
469
 
398
470
  # Render all elements
399
- typst += "".join(element.render("typst", **kwargs) for element in self.elements)
471
+ typst += "".join(element.render(ContentAST.OutputFormat.TYPST, **kwargs) for element in self.elements)
400
472
 
401
473
  return typst
402
474
 
403
- class Question(Element):
475
+ class Question(Container):
404
476
  """
405
477
  Complete question container with body, explanation, and metadata.
406
478
 
@@ -436,7 +508,8 @@ class ContentAST:
436
508
  interest=1.0,
437
509
  spacing=0,
438
510
  topic=None,
439
- question_number=None
511
+ question_number=None,
512
+ **kwargs
440
513
  ):
441
514
  super().__init__()
442
515
  self.name = name
@@ -447,14 +520,21 @@ class ContentAST:
447
520
  self.spacing = spacing
448
521
  self.topic = topic # todo: remove this bs.
449
522
  self.question_number = question_number # For QR code generation
523
+
524
+ self.default_kwargs = kwargs
450
525
 
451
526
  def render(self, output_format, **kwargs):
527
+ updated_kwargs = self.default_kwargs
528
+ updated_kwargs.update(kwargs)
529
+
530
+ log.debug(f"updated_kwargs: {updated_kwargs}")
531
+
452
532
  # Special handling for latex and typst - use dedicated render methods
453
533
  if output_format == "typst":
454
534
  return self.render_typst(**kwargs)
455
535
 
456
536
  # Generate content from all elements
457
- content = self.body.render(output_format, **kwargs)
537
+ content = self.body.render(output_format, **updated_kwargs)
458
538
 
459
539
  # If output format is latex, add in minipage and question environments
460
540
  if output_format == "latex":
@@ -522,7 +602,7 @@ class ContentAST:
522
602
  def render_typst(self, **kwargs):
523
603
  """Render question in Typst format with proper formatting"""
524
604
  # Render question body
525
- content = self.body.render("typst", **kwargs)
605
+ content = self.body.render(ContentAST.OutputFormat.TYPST, **kwargs)
526
606
 
527
607
  # Generate QR code if question number is available
528
608
  qr_param = ""
@@ -564,9 +644,40 @@ class ContentAST:
564
644
  {qr_param}
565
645
  )[
566
646
  """) + content + "\n]\n\n"
567
-
647
+
648
+ class Section(Container):
649
+ """
650
+ Primary container for question content - USE THIS for get_body() and get_explanation().
651
+
652
+ This is the most important ContentAST class for question developers.
653
+ It serves as the main container for organizing question content
654
+ and should be the return type for your get_body() and get_explanation() methods.
655
+
656
+ CRITICAL: Always use ContentAST.Section as the container for:
657
+ - Question body content (return from get_body())
658
+ - Question explanation/solution content (return from get_explanation())
659
+ - Any grouped content that needs to render together
660
+
661
+ When to use:
662
+ - As the root container in get_body() and get_explanation() methods
663
+ - Grouping related content elements
664
+ - Organizing complex question content
665
+
666
+ Example:
667
+ def get_body(self):
668
+ body = ContentAST.Section()
669
+ body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
670
+
671
+ matrix_data = [[1, 2], [3, 4]]
672
+ body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
673
+
674
+ body.add_element(ContentAST.Answer(answer=self.answer, label="Determinant"))
675
+ return body
676
+ """
677
+ pass
678
+
568
679
  # Individual elements
569
- class Text(Element):
680
+ class Text(Leaf):
570
681
  """
571
682
  Basic text content with automatic format conversion and selective visibility.
572
683
 
@@ -595,8 +706,7 @@ class ContentAST:
595
706
  web_note = ContentAST.Text("Click submit", hide_from_latex=True)
596
707
  """
597
708
  def __init__(self, content : str, *, hide_from_latex=False, hide_from_html=False, emphasis=False):
598
- super().__init__()
599
- self.content = content
709
+ super().__init__(content)
600
710
  self.hide_from_latex = hide_from_latex
601
711
  self.hide_from_html = hide_from_html
602
712
  self.emphasis = emphasis
@@ -607,26 +717,19 @@ class ContentAST:
607
717
  def render_html(self, **kwargs):
608
718
  if self.hide_from_html:
609
719
  return ""
610
- # If the super function returns None then we just return content as is
611
- conversion_results = super().convert_markdown(self.content.replace("#", r"\#"), "html").strip()
612
- if conversion_results is not None:
613
- if conversion_results.startswith("<p>") and conversion_results.endswith("</p>"):
614
- conversion_results = conversion_results[3:-4]
615
- return conversion_results or self.content
616
-
720
+ return self.convert_markdown(self.content,ContentAST.OutputFormat.HTML)
721
+
617
722
  def render_latex(self, **kwargs):
618
723
  if self.hide_from_latex:
619
724
  return ""
620
- # Escape # to prevent markdown header conversion in LaTeX
621
- content = super().convert_markdown(self.content.replace("#", r"\#"), "latex") or self.content
622
- return content
725
+ return self.convert_markdown(self.content.replace("#", r"\#"), ContentAST.OutputFormat.LATEX)
623
726
 
624
727
  def render_typst(self, **kwargs):
625
728
  """Render text to Typst, escaping special characters."""
626
- # Hide HTML-only text from Typst (since Typst generates PDFs like LaTeX)
627
729
  if self.hide_from_latex:
628
730
  return ""
629
731
 
732
+ # This is for when we are passing in a code block via a FromText question
630
733
  content = re.sub(
631
734
  r"```\s*(.*)\s*```",
632
735
  r"""
@@ -642,12 +745,9 @@ class ContentAST:
642
745
 
643
746
  # In Typst, # starts code/function calls, so we need to escape it
644
747
  content = content.replace("# ", r"\# ")
645
- # content = self.content
646
-
647
- # Apply emphasis if needed
748
+
648
749
  if self.emphasis:
649
750
  content = f"*{content}*"
650
-
651
751
  return content
652
752
 
653
753
  def is_mergeable(self, other: ContentAST.Element):
@@ -695,31 +795,25 @@ class ContentAST:
695
795
  self.make_normal = kwargs.get("make_normal", False)
696
796
 
697
797
  def render_markdown(self, **kwargs):
698
- content = "```\n" + self.content + "\n```"
798
+ content = "```" + self.content.rstrip() + "\n```"
699
799
  return content
700
800
 
701
801
  def render_html(self, **kwargs):
702
-
703
- return super().convert_markdown(textwrap.indent(self.content, "\t"), "html") or self.content
802
+ return self.convert_markdown(textwrap.indent(self.content, "\t"), ContentAST.OutputFormat.HTML)
704
803
 
705
804
  def render_latex(self, **kwargs):
706
- content = super().convert_markdown(self.render_markdown(), "latex") or self.content
707
- if self.make_normal:
708
- content = (
709
- r"{\normal "
710
- + content +
711
- r"}"
712
- )
713
- return content
805
+ return self.convert_markdown(self.render_markdown(), ContentAST.OutputFormat.LATEX)
714
806
 
715
807
  def render_typst(self, **kwargs):
716
808
  """Render code block in Typst with smaller monospace font."""
717
809
  # Use raw block with 11pt font size
718
810
  # Escape backticks in the content
719
811
  escaped_content = self.content.replace("`", r"\`")
720
- 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()
721
815
 
722
- class Equation(Element):
816
+ class Equation(Leaf):
723
817
  """
724
818
  Mathematical equation renderer with LaTeX input and cross-format output.
725
819
 
@@ -748,7 +842,7 @@ class ContentAST:
748
842
  body.add_element(ContentAST.Equation(r"\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}"))
749
843
  """
750
844
  def __init__(self, latex, inline=False):
751
- super().__init__()
845
+ super().__init__("[equation]")
752
846
  self.latex = latex
753
847
  self.inline = inline
754
848
 
@@ -861,7 +955,7 @@ class ContentAST:
861
955
 
862
956
  return cls('\n'.join(equation_lines))
863
957
 
864
- class Matrix(Element):
958
+ class Matrix(Leaf):
865
959
  """
866
960
  Mathematical matrix renderer for consistent cross-format display.
867
961
 
@@ -891,21 +985,36 @@ class ContentAST:
891
985
  - "B": curly braces {matrix}
892
986
  - "V": double vertical bars ||matrix|| - for norms
893
987
  """
894
- def __init__(self, data, bracket_type="p", inline=False):
988
+ def __init__(self, data, *, bracket_type="p", inline=False, name=None):
895
989
  """
896
990
  Creates a matrix element that renders consistently across output formats.
897
991
 
898
992
  Args:
899
- data: Matrix data as List[List[numbers/strings]]
900
- For vectors: [[v1], [v2], [v3]] (column vector)
901
- For matrices: [[a, b], [c, d]]
993
+ data: Matrix data as List[List[numbers/strings]] or numpy ndarray (1D or 2D)
994
+ For vectors: [[v1], [v2], [v3]] (column vector) or np.array([v1, v2, v3])
995
+ For matrices: [[a, b], [c, d]] or np.array([[a, b], [c, d]])
902
996
  bracket_type: Bracket style - "b" for [], "p" for (), "v" for |, etc.
903
997
  inline: Whether to use inline (smaller) matrix formatting
904
998
  """
905
- super().__init__()
906
- 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
+
907
1015
  self.bracket_type = bracket_type
908
1016
  self.inline = inline
1017
+ self.name = name
909
1018
 
910
1019
  @staticmethod
911
1020
  def to_latex(data, bracket_type="p"):
@@ -944,14 +1053,19 @@ class ContentAST:
944
1053
  def render_html(self, **kwargs):
945
1054
  matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
946
1055
  rows = []
947
- 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:
948
1061
  rows.append(" & ".join(str(cell) for cell in row))
949
1062
  matrix_content = r" \\ ".join(rows)
950
1063
 
951
1064
  if self.inline:
952
1065
  return f"<span class='math'>$\\big(\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\big)$</span>"
953
1066
  else:
954
- 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>"
955
1069
 
956
1070
  def render_latex(self, **kwargs):
957
1071
  matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
@@ -965,7 +1079,41 @@ class ContentAST:
965
1079
  else:
966
1080
  return f"\\[\\begin{{{matrix_env}}} {matrix_content} \\end{{{matrix_env}}}\\]"
967
1081
 
968
- 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):
969
1117
  """
970
1118
  Image/diagram container with proper sizing and captioning.
971
1119
 
@@ -997,7 +1145,7 @@ class ContentAST:
997
1145
  body.add_element(picture)
998
1146
  """
999
1147
  def __init__(self, img_data, caption=None, width=None):
1000
- super().__init__()
1148
+ super().__init__("[picture]")
1001
1149
  self.img_data = img_data
1002
1150
  self.caption = caption
1003
1151
  self.width = width
@@ -1060,8 +1208,165 @@ class ContentAST:
1060
1208
 
1061
1209
  result.append("\\end{figure}")
1062
1210
  return "\n".join(result)
1211
+
1212
+ def render_typst(self, **kwargs):
1213
+ self._ensure_image_saved()
1214
+
1215
+ # Build the image function call
1216
+ img_params = []
1217
+ if self.width:
1218
+ img_params.append(f'width: {self.width}')
1219
+
1220
+ params_str = ', '.join(img_params) if img_params else ''
1221
+
1222
+ # Use Typst's figure and image functions
1223
+ result = []
1224
+ result.append("#figure(")
1225
+ result.append(f' image("{self.path}"{", " + params_str if params_str else ""}),')
1226
+
1227
+ if self.caption:
1228
+ result.append(f' caption: [{self.caption}]')
1229
+
1230
+ result.append(")")
1231
+
1232
+ return "\n".join(result)
1233
+
1234
+ class Answer(Leaf):
1235
+ """
1236
+ Answer input field that renders as blanks in PDF and shows answers in HTML.
1237
+
1238
+ CRITICAL: Use this for ALL answer inputs in questions.
1239
+ Creates appropriate input fields that work across both PDF and Canvas formats.
1240
+ In PDF, renders as blank lines for students to fill in.
1241
+ In HTML/Canvas, can display the answer for checking.
1242
+
1243
+ When to use:
1244
+ - Any place where students need to input an answer
1245
+ - Numerical answers, short text answers, etc.
1246
+ - Questions requiring fill-in-the-blank responses
1247
+
1248
+ Example:
1249
+ # Basic answer field
1250
+ body.add_element(ContentAST.Answer(
1251
+ answer=self.answer,
1252
+ label="Result",
1253
+ unit="MB"
1254
+ ))
1255
+
1256
+ # Multiple choice or complex answers
1257
+ body.add_element(ContentAST.Answer(
1258
+ answer=[self.answer_a, self.answer_b],
1259
+ label="Choose the best answer"
1260
+ ))
1261
+ """
1262
+
1263
+ def __init__(self, answer, label: str = "", unit: str = "", blank_length=5):
1264
+ super().__init__(label)
1265
+ self.answer = answer
1266
+ self.label = label
1267
+ self.unit = unit
1268
+ self.length = blank_length
1269
+
1270
+ def render_markdown(self, **kwargs):
1271
+ if not isinstance(self.answer, list):
1272
+ key_to_display = self.answer.key
1273
+ else:
1274
+ key_to_display = self.answer[0].key
1275
+ return f"{self.label + (':' if len(self.label) > 0 else '')} [{key_to_display}] {self.unit}".strip()
1276
+
1277
+ def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
1278
+ if can_be_numerical:
1279
+ return f"Calculate {self.label}"
1280
+ if show_answers and self.answer:
1281
+ # Show actual answer value using formatted display string
1282
+ if not isinstance(self.answer, list):
1283
+ answer_display = self.answer.get_display_string()
1284
+ else:
1285
+ answer_display = ", ".join(a.get_display_string() for a in self.answer)
1286
+
1287
+ label_part = f"{self.label}:" if self.label else ""
1288
+ unit_part = f" {self.unit}" if self.unit else ""
1289
+ return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
1290
+ else:
1291
+ # Default behavior: show [key]
1292
+ return self.render_markdown(**kwargs)
1293
+
1294
+ def render_latex(self, **kwargs):
1295
+ return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.length}}} {self.unit}".strip()
1296
+
1297
+ def render_typst(self, **kwargs):
1298
+ """Render answer blank as an underlined space in Typst."""
1299
+ # Use the fillline function defined in TYPST_HEADER
1300
+ # Width is based on self.length (in cm)
1301
+ blank_width = self.length * 0.75 # Convert character length to cm
1302
+ blank = f"#fillline(width: {blank_width}cm)"
1303
+
1304
+ label_part = f"{self.label}:" if self.label else ""
1305
+ unit_part = f" {self.unit}" if self.unit else ""
1306
+
1307
+ return f"{label_part} {blank}{unit_part}".strip()
1308
+
1309
+ class LineBreak(Text):
1310
+ def __init__(self, *args, **kwargs):
1311
+ super().__init__("\n\n")
1312
+
1313
+ ## Containers
1314
+
1315
+ class Paragraph(Container):
1316
+ """
1317
+ Text block container with proper spacing and paragraph formatting.
1318
+
1319
+ IMPORTANT: Use this for grouping text content, especially in question bodies.
1320
+ Automatically handles spacing between paragraphs and combines multiple
1321
+ lines/elements into a cohesive text block.
1322
+
1323
+ When to use:
1324
+ - Question instructions or problem statements
1325
+ - Multi-line text content
1326
+ - Grouping related text elements
1327
+ - Any text that should be visually separated as a paragraph
1328
+
1329
+ When NOT to use:
1330
+ - Single words or short phrases (use ContentAST.Text)
1331
+ - Mathematical content (use ContentAST.Equation)
1332
+ - Structured data (use ContentAST.Table)
1333
+
1334
+ Example:
1335
+ # Multi-line question text
1336
+ body.add_element(ContentAST.Paragraph([
1337
+ "Consider the following system:",
1338
+ "- Process A requires 4MB memory",
1339
+ "- Process B requires 2MB memory",
1340
+ "How much total memory is needed?"
1341
+ ]))
1342
+
1343
+ # Mixed content paragraph
1344
+ para = ContentAST.Paragraph([
1345
+ "The equation ",
1346
+ ContentAST.Equation("x^2 + 1 = 0", inline=True),
1347
+ " has no real solutions."
1348
+ ])
1349
+ """
1350
+
1351
+ def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
1352
+ super().__init__(add_spacing_before=True)
1353
+ for line in lines_or_elements:
1354
+ if isinstance(line, str):
1355
+ self.elements.append(ContentAST.Text(line))
1356
+ else:
1357
+ self.elements.append(line)
1358
+
1359
+ def render(self, output_format, **kwargs):
1360
+ # Add in new lines to break these up visually
1361
+ return "\n\n" + super().render(output_format, **kwargs) + "\n\n"
1362
+
1363
+ def render_html(self, **kwargs):
1364
+ return super().render_html(**kwargs) + "<br>"
1365
+
1366
+ def add_line(self, line: str):
1367
+ self.elements.append(ContentAST.Text(line))
1063
1368
 
1064
- class Table(Element):
1369
+ class Table(Container):
1065
1370
  """
1066
1371
  Structured data table with cross-format rendering and proper formatting.
1067
1372
 
@@ -1291,166 +1596,7 @@ class ContentAST:
1291
1596
 
1292
1597
  return "\n#box(" + "\n".join(result) + "\n)"
1293
1598
 
1294
- ## Containers
1295
- class Section(Element):
1296
- """
1297
- Primary container for question content - USE THIS for get_body() and get_explanation().
1298
-
1299
- This is the most important ContentAST class for question developers.
1300
- It serves as the main container for organizing question content
1301
- and should be the return type for your get_body() and get_explanation() methods.
1302
-
1303
- CRITICAL: Always use ContentAST.Section as the container for:
1304
- - Question body content (return from get_body())
1305
- - Question explanation/solution content (return from get_explanation())
1306
- - Any grouped content that needs to render together
1307
-
1308
- When to use:
1309
- - As the root container in get_body() and get_explanation() methods
1310
- - Grouping related content elements
1311
- - Organizing complex question content
1312
-
1313
- Example:
1314
- def get_body(self):
1315
- body = ContentAST.Section()
1316
- body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
1317
-
1318
- matrix_data = [[1, 2], [3, 4]]
1319
- body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
1320
-
1321
- body.add_element(ContentAST.Answer(answer=self.answer, label="Determinant"))
1322
- return body
1323
- """
1324
-
1325
- def render_typst(self, **kwargs):
1326
- """Render section by directly calling render on each child element."""
1327
- return "".join(element.render("typst", **kwargs) for element in self.elements)
1328
-
1329
- class Paragraph(Element):
1330
- """
1331
- Text block container with proper spacing and paragraph formatting.
1332
-
1333
- IMPORTANT: Use this for grouping text content, especially in question bodies.
1334
- Automatically handles spacing between paragraphs and combines multiple
1335
- lines/elements into a cohesive text block.
1336
-
1337
- When to use:
1338
- - Question instructions or problem statements
1339
- - Multi-line text content
1340
- - Grouping related text elements
1341
- - Any text that should be visually separated as a paragraph
1342
-
1343
- When NOT to use:
1344
- - Single words or short phrases (use ContentAST.Text)
1345
- - Mathematical content (use ContentAST.Equation)
1346
- - Structured data (use ContentAST.Table)
1347
-
1348
- Example:
1349
- # Multi-line question text
1350
- body.add_element(ContentAST.Paragraph([
1351
- "Consider the following system:",
1352
- "- Process A requires 4MB memory",
1353
- "- Process B requires 2MB memory",
1354
- "How much total memory is needed?"
1355
- ]))
1356
-
1357
- # Mixed content paragraph
1358
- para = ContentAST.Paragraph([
1359
- "The equation ",
1360
- ContentAST.Equation("x^2 + 1 = 0", inline=True),
1361
- " has no real solutions."
1362
- ])
1363
- """
1364
-
1365
- def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
1366
- super().__init__(add_spacing_before=True)
1367
- for line in lines_or_elements:
1368
- if isinstance(line, str):
1369
- self.elements.append(ContentAST.Text(line))
1370
- else:
1371
- self.elements.append(line)
1372
-
1373
- def render(self, output_format, **kwargs):
1374
- # Add in new lines to break these up visually
1375
- return "\n\n" + super().render(output_format, **kwargs)
1376
-
1377
- def add_line(self, line: str):
1378
- self.elements.append(ContentAST.Text(line))
1379
-
1380
- class Answer(Element):
1381
- """
1382
- Answer input field that renders as blanks in PDF and shows answers in HTML.
1383
-
1384
- CRITICAL: Use this for ALL answer inputs in questions.
1385
- Creates appropriate input fields that work across both PDF and Canvas formats.
1386
- In PDF, renders as blank lines for students to fill in.
1387
- In HTML/Canvas, can display the answer for checking.
1388
-
1389
- When to use:
1390
- - Any place where students need to input an answer
1391
- - Numerical answers, short text answers, etc.
1392
- - Questions requiring fill-in-the-blank responses
1393
-
1394
- Example:
1395
- # Basic answer field
1396
- body.add_element(ContentAST.Answer(
1397
- answer=self.answer,
1398
- label="Result",
1399
- unit="MB"
1400
- ))
1401
-
1402
- # Multiple choice or complex answers
1403
- body.add_element(ContentAST.Answer(
1404
- answer=[self.answer_a, self.answer_b],
1405
- label="Choose the best answer"
1406
- ))
1407
- """
1408
-
1409
- def __init__(self, answer: Answer = None, label: str = "", unit: str = "", blank_length=5):
1410
- super().__init__()
1411
- self.answer = answer
1412
- self.label = label
1413
- self.unit = unit
1414
- self.length = blank_length
1415
-
1416
- def render_markdown(self, **kwargs):
1417
- if not isinstance(self.answer, list):
1418
- key_to_display = self.answer.key
1419
- else:
1420
- key_to_display = self.answer[0].key
1421
- return f"{self.label + (':' if len(self.label) > 0 else '')} [{key_to_display}] {self.unit}".strip()
1422
-
1423
- def render_html(self, show_answers=False, **kwargs):
1424
- if show_answers and self.answer:
1425
- # Show actual answer value using formatted display string
1426
- if not isinstance(self.answer, list):
1427
- answer_display = self.answer.get_display_string()
1428
- else:
1429
- answer_display = ", ".join(a.get_display_string() for a in self.answer)
1430
-
1431
- label_part = f"{self.label}:" if self.label else ""
1432
- unit_part = f" {self.unit}" if self.unit else ""
1433
- return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
1434
- else:
1435
- # Default behavior: show [key]
1436
- return self.render_markdown(**kwargs)
1437
-
1438
- def render_latex(self, **kwargs):
1439
- return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.length}}} {self.unit}".strip()
1440
-
1441
- def render_typst(self, **kwargs):
1442
- """Render answer blank as an underlined space in Typst."""
1443
- # Use the fillline function defined in TYPST_HEADER
1444
- # Width is based on self.length (in cm)
1445
- blank_width = self.length * 0.75 # Convert character length to cm
1446
- blank = f"#fillline(width: {blank_width}cm)"
1447
-
1448
- label_part = f"{self.label}:" if self.label else ""
1449
- unit_part = f" {self.unit}" if self.unit else ""
1450
-
1451
- return f"{label_part} {blank}{unit_part}".strip()
1452
-
1453
- class TableGroup(Element):
1599
+ class TableGroup(Container):
1454
1600
  """
1455
1601
  Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
1456
1602
 
@@ -1638,7 +1784,7 @@ class ContentAST:
1638
1784
  return content
1639
1785
 
1640
1786
  ## Specialized Elements
1641
- class RepeatedProblemPart(Element):
1787
+ class RepeatedProblemPart(Container):
1642
1788
  """
1643
1789
  Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
1644
1790
 
@@ -1753,7 +1899,7 @@ class ContentAST:
1753
1899
  result.append(r"\end{alignat*}")
1754
1900
  return "\n".join(result)
1755
1901
 
1756
- class OnlyLatex(Element):
1902
+ class OnlyLatex(Container):
1757
1903
  """
1758
1904
  Container element that only renders content in LaTeX/PDF output format.
1759
1905
 
@@ -1775,12 +1921,12 @@ class ContentAST:
1775
1921
  body.add_element(latex_only)
1776
1922
  """
1777
1923
 
1778
- def render(self, output_format, **kwargs):
1924
+ def render(self, output_format: ContentAST.OutputFormat, **kwargs):
1779
1925
  if output_format != "latex":
1780
1926
  return ""
1781
- return super().render(output_format, **kwargs)
1927
+ return super().render(output_format=output_format, **kwargs)
1782
1928
 
1783
- class OnlyHtml(Element):
1929
+ class OnlyHtml(Container):
1784
1930
  """
1785
1931
  Container element that only renders content in HTML/Canvas output format.
1786
1932