QuizGenerator 0.4.2__py3-none-any.whl → 0.6.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.
Files changed (33) hide show
  1. QuizGenerator/contentast.py +809 -117
  2. QuizGenerator/generate.py +219 -11
  3. QuizGenerator/misc.py +0 -556
  4. QuizGenerator/mixins.py +50 -29
  5. QuizGenerator/premade_questions/basic.py +3 -3
  6. QuizGenerator/premade_questions/cst334/languages.py +183 -175
  7. QuizGenerator/premade_questions/cst334/math_questions.py +81 -70
  8. QuizGenerator/premade_questions/cst334/memory_questions.py +262 -165
  9. QuizGenerator/premade_questions/cst334/persistence_questions.py +83 -60
  10. QuizGenerator/premade_questions/cst334/process.py +558 -79
  11. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +39 -13
  12. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +61 -36
  13. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +29 -10
  14. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +2 -2
  15. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +60 -43
  16. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +173 -326
  17. QuizGenerator/premade_questions/cst463/models/attention.py +29 -14
  18. QuizGenerator/premade_questions/cst463/models/cnns.py +32 -20
  19. QuizGenerator/premade_questions/cst463/models/rnns.py +28 -15
  20. QuizGenerator/premade_questions/cst463/models/text.py +29 -15
  21. QuizGenerator/premade_questions/cst463/models/weight_counting.py +38 -30
  22. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +91 -111
  23. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +128 -55
  24. QuizGenerator/question.py +114 -20
  25. QuizGenerator/quiz.py +81 -24
  26. QuizGenerator/regenerate.py +98 -29
  27. {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/METADATA +1 -1
  28. {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/RECORD +31 -33
  29. QuizGenerator/README.md +0 -5
  30. QuizGenerator/logging.yaml +0 -55
  31. {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/WHEEL +0 -0
  32. {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/entry_points.txt +0 -0
  33. {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -2,23 +2,28 @@ from __future__ import annotations
2
2
 
3
3
  import abc
4
4
  import enum
5
- import re
6
5
  import textwrap
7
6
  from io import BytesIO
8
7
  from typing import List, Callable
9
8
 
10
- import numpy
11
9
  import pypandoc
12
10
  import markdown
13
11
 
14
- # from QuizGenerator.misc import Answer
15
-
16
12
  from QuizGenerator.qrcode_generator import QuestionQRCode
17
13
  import re
18
14
 
19
15
  import logging
16
+ import os
17
+ import uuid
18
+ import itertools
19
+ import math
20
+ import decimal
21
+ import fractions
22
+ import numpy as np
23
+
20
24
  log = logging.getLogger(__name__)
21
25
 
26
+
22
27
  class ContentAST:
23
28
  """
24
29
  Content Abstract Syntax Tree - The core content system for quiz generation.
@@ -664,15 +669,19 @@ class ContentAST:
664
669
  - Organizing complex question content
665
670
 
666
671
  Example:
667
- def get_body(self):
672
+ def _get_body(self):
668
673
  body = ContentAST.Section()
674
+ answers = []
669
675
  body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
670
676
 
671
677
  matrix_data = [[1, 2], [3, 4]]
672
678
  body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
673
679
 
674
- body.add_element(ContentAST.Answer(answer=self.answer, label="Determinant"))
675
- return body
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
676
685
  """
677
686
  pass
678
687
 
@@ -729,23 +738,41 @@ class ContentAST:
729
738
  if self.hide_from_latex:
730
739
  return ""
731
740
 
732
- # This is for when we are passing in a code block via a FromText question
733
- content = re.sub(
734
- r"```\s*(.*)\s*```",
735
- r"""
741
+ # Extract code blocks, render them, and replace with placeholders
742
+ # This prevents the # escaping from affecting content inside code blocks
743
+ code_blocks = []
744
+
745
+ def save_code_block(match):
746
+ code_content = match.group(1).strip()
747
+ # Escape quotes for Typst raw() function
748
+ escaped_content = code_content.replace('"', r'\"')
749
+ rendered_block = f"""
736
750
  #box(
737
- raw("\1",
751
+ raw("{escaped_content}",
738
752
  block: true
739
753
  )
740
754
  )
741
- """,
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,
742
764
  self.content,
743
765
  flags=re.DOTALL
744
766
  )
745
767
 
746
- # In Typst, # starts code/function calls, so we need to escape it
768
+ # In Typst, # starts code/function calls, so we need to escape it in regular text
769
+ # (but not in code blocks, which are now placeholders)
747
770
  content = content.replace("# ", r"\# ")
748
-
771
+
772
+ # Restore code blocks
773
+ for i, block in enumerate(code_blocks):
774
+ content = content.replace(f"__CODE_BLOCK_{i}__", block)
775
+
749
776
  if self.emphasis:
750
777
  content = f"*{content}*"
751
778
  return content
@@ -903,8 +930,19 @@ class ContentAST:
903
930
  # Convert subscripts and superscripts from LaTeX to Typst
904
931
  # LaTeX uses braces: b_{out}, x_{10}, x^{2}
905
932
  # Typst uses parentheses for multi-char: b_(out), x_(10), x^(2)
906
- latex_str = re.sub(r'_{([^}]+)}', r'_("\1")', latex_str) # _{...} -> _(...)
907
- latex_str = re.sub(r'\^{([^}]+)}', r'^("\1")', latex_str) # ^{...} -> ^(...)
933
+ # Multi-character text subscripts need quotes: L_{base} -> L_("base")
934
+ # But numbers don't: x_{10} -> x_(10)
935
+ def convert_sub_super(match):
936
+ content = match.group(1)
937
+ prefix = match.group(0)[0] # '_' or '^'
938
+ # If it's purely numeric or a single char, no quotes needed
939
+ if content.isdigit() or len(content) == 1:
940
+ return f'{prefix}({content})'
941
+ # If it's multi-char text, quote it
942
+ return f'{prefix}("{content}")'
943
+
944
+ latex_str = re.sub(r'_{([^}]+)}', convert_sub_super, latex_str)
945
+ latex_str = re.sub(r'\^{([^}]+)}', convert_sub_super, latex_str)
908
946
 
909
947
  # Convert LaTeX Greek letters to Typst syntax (remove backslash)
910
948
  greek_letters = [
@@ -922,19 +960,66 @@ class ContentAST:
922
960
  latex_str = latex_str.replace(r'\nabla', 'nabla')
923
961
  latex_str = latex_str.replace(r'\times', 'times')
924
962
  latex_str = latex_str.replace(r'\cdot', 'dot')
925
- latex_str = latex_str.replace(r'\partial', 'diff')
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
+
992
+ for env_type, delim in bracket_map.items():
993
+ pattern = rf'\\begin\{{{env_type}\}}(.*?)\\end\{{{env_type}\}}'
994
+
995
+ def make_replacer(delimiter):
996
+ def replace_matrix(match):
997
+ content = match.group(1)
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)
926
1012
 
927
- # Handle matrix environments
928
- if r'\begin{matrix}' in latex_str:
929
- matrix_pattern = r'\[\\begin\{matrix\}(.*?)\\end\{matrix\}\]'
1013
+ if delimiter:
1014
+ return f"mat(delim: {delimiter}, {elements})"
1015
+ else:
1016
+ return f"mat({elements})"
1017
+ return replace_matrix
930
1018
 
931
- def replace_matrix(match):
932
- content = match.group(1)
933
- elements = content.split(r'\\')
934
- elements = [e.strip() for e in elements if e.strip()]
935
- return f"vec({', '.join(elements)})"
1019
+ latex_str = re.sub(pattern, make_replacer(delim), latex_str, flags=re.DOTALL)
936
1020
 
937
- latex_str = re.sub(matrix_pattern, replace_matrix, latex_str)
1021
+ # Handle \left\| ... \right\| for norms
1022
+ latex_str = re.sub(r'\\\|', '||', latex_str)
938
1023
 
939
1024
  return latex_str
940
1025
 
@@ -955,6 +1040,125 @@ class ContentAST:
955
1040
 
956
1041
  return cls('\n'.join(equation_lines))
957
1042
 
1043
+ class MathExpression(Leaf):
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}$"
1140
+
1141
+ def render_html(self, **kwargs):
1142
+ content = self._render_parts(ContentAST.OutputFormat.HTML, **kwargs)
1143
+ if self.inline:
1144
+ return f"\\({content}\\)"
1145
+ else:
1146
+ return f"<div class='math'>$$ \\displaystyle {content} \\; $$</div>"
1147
+
1148
+ def render_latex(self, **kwargs):
1149
+ content = self._render_parts(ContentAST.OutputFormat.LATEX, **kwargs)
1150
+ if self.inline:
1151
+ return f"${content}$~"
1152
+ else:
1153
+ return f"\\begin{{flushleft}}${content}$\\end{{flushleft}}"
1154
+
1155
+ def render_typst(self, **kwargs):
1156
+ content = self._render_parts(ContentAST.OutputFormat.TYPST, **kwargs)
1157
+ if self.inline:
1158
+ return f"${content}$"
1159
+ else:
1160
+ return f" $ {content} $ "
1161
+
958
1162
  class Matrix(Leaf):
959
1163
  """
960
1164
  Mathematical matrix renderer for consistent cross-format display.
@@ -999,7 +1203,6 @@ class ContentAST:
999
1203
  super().__init__("[matrix]")
1000
1204
 
1001
1205
  # Convert numpy ndarray to list format if needed
1002
- import numpy as np
1003
1206
  if isinstance(data, np.ndarray):
1004
1207
  if data.ndim == 1:
1005
1208
  # 1D array: convert to column vector [[v1], [v2], [v3]]
@@ -1038,6 +1241,42 @@ class ContentAST:
1038
1241
  matrix_content = r" \\ ".join(rows)
1039
1242
  return f"\\begin{{{bracket_type}matrix}} {matrix_content} \\end{{{bracket_type}matrix}}"
1040
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
+
1041
1280
  def render_markdown(self, **kwargs):
1042
1281
  matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1043
1282
  rows = []
@@ -1053,7 +1292,7 @@ class ContentAST:
1053
1292
  def render_html(self, **kwargs):
1054
1293
  matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1055
1294
  rows = []
1056
- if isinstance(self.data, numpy.ndarray):
1295
+ if isinstance(self.data, np.ndarray):
1057
1296
  data = self.data.tolist()
1058
1297
  else:
1059
1298
  data = self.data
@@ -1154,8 +1393,6 @@ class ContentAST:
1154
1393
  def _ensure_image_saved(self):
1155
1394
  """Save image data to file if not already saved."""
1156
1395
  if self.path is None:
1157
- import os
1158
- import uuid
1159
1396
 
1160
1397
  # Create imgs directory if it doesn't exist (use absolute path)
1161
1398
  img_dir = os.path.abspath("imgs")
@@ -1231,81 +1468,6 @@ class ContentAST:
1231
1468
 
1232
1469
  return "\n".join(result)
1233
1470
 
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
1471
  class LineBreak(Text):
1310
1472
  def __init__(self, *args, **kwargs):
1311
1473
  super().__init__("\n\n")
@@ -1745,18 +1907,15 @@ class ContentAST:
1745
1907
  - Better visual grouping of related answers
1746
1908
 
1747
1909
  Example:
1748
- # Multiple related answers
1749
- answers = [
1750
- ContentAST.Answer(answer=self.memory_answer, label="Memory used", unit="MB"),
1751
- ContentAST.Answer(answer=self.time_answer, label="Execution time", unit="ms")
1752
- ]
1753
- answer_block = ContentAST.AnswerBlock(answers)
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])
1754
1914
  body.add_element(answer_block)
1755
1915
 
1756
1916
  # Single answer with better spacing
1757
- single_answer = ContentAST.AnswerBlock(
1758
- ContentAST.Answer(answer=self.result, label="Final result")
1759
- )
1917
+ result_ans = ContentAST.Answer.integer("result", self.result_value, label="Final result")
1918
+ single_answer = ContentAST.AnswerBlock(result_ans)
1760
1919
  """
1761
1920
  def __init__(self, answers: ContentAST.Answer|List[ContentAST.Answer]):
1762
1921
  if not isinstance(answers, list):
@@ -1920,9 +2079,9 @@ class ContentAST:
1920
2079
  # Add to main content - only appears in PDF
1921
2080
  body.add_element(latex_only)
1922
2081
  """
1923
-
2082
+
1924
2083
  def render(self, output_format: ContentAST.OutputFormat, **kwargs):
1925
- if output_format != "latex":
2084
+ if output_format not in ("latex", "typst"):
1926
2085
  return ""
1927
2086
  return super().render(output_format=output_format, **kwargs)
1928
2087
 
@@ -1952,4 +2111,537 @@ class ContentAST:
1952
2111
  if output_format != "html":
1953
2112
  return ""
1954
2113
  return super().render(output_format, **kwargs)
2114
+
2115
+ class Answer(Leaf):
2116
+ """
2117
+ Unified answer class combining data storage, Canvas export, and rendering.
2118
+
2119
+ Extends ContentAST.Leaf to integrate seamlessly with the ContentAST tree while
2120
+ maintaining all Canvas export functionality.
2121
+
2122
+ CRITICAL: Use this for ALL answer inputs in questions.
2123
+ Creates appropriate input fields that work across both PDF and Canvas formats.
2124
+ In PDF, renders as blank lines for students to fill in.
2125
+ In HTML/Canvas, can display the answer for checking.
2126
+
2127
+ Example:
2128
+ # Basic answer field
2129
+ ans = ContentAST.Answer.integer("result", 42, label="Result", unit="MB")
2130
+ body.add_element(ans)
2131
+ answers.append(ans) # Track for Canvas export
2132
+ """
2133
+
2134
+ DEFAULT_ROUNDING_DIGITS = 4
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
2189
+
2190
+ self.length = length # Used for bits and hex to be printed appropriately
2191
+ self.correct = correct
2192
+ self.baffles = baffles
2193
+ self.pdf_only = pdf_only
2194
+
2195
+ # Rendering fields
2196
+ self.label = label
2197
+ self.unit = unit
2198
+ self.blank_length = blank_length
2199
+
2200
+ # Canvas export methods (from misc.Answer)
2201
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2202
+ """Generate Canvas answer dictionaries based on variable_kind."""
2203
+
2204
+ # If this answer is PDF-only, don't send it to Canvas
2205
+ if self.pdf_only:
2206
+ return []
2207
+
2208
+ canvas_answers = []
2209
+ if isinstance(self.value, list):
2210
+ canvas_answers = [
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 = [{
2220
+ "blank_id": self.key,
2221
+ "answer_text": self.value,
2222
+ "answer_weight": 100 if self.correct else 0,
2223
+ }]
2224
+
2225
+ # Add baffles (incorrect answer choices)
2226
+ if self.baffles is not None:
2227
+ for baffle in self.baffles:
2228
+ canvas_answers.append({
2229
+ "blank_id": self.key,
2230
+ "answer_text": baffle,
2231
+ "answer_weight": 0,
2232
+ })
2233
+
2234
+ return canvas_answers
2235
+
2236
+ def get_display_string(self) -> str:
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)
2254
+
2255
+ def render_latex(self, **kwargs):
2256
+ return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.blank_length}}} {self.unit}".strip()
2257
+
2258
+ def render_typst(self, **kwargs):
2259
+ """Render answer blank as an underlined space in Typst."""
2260
+ blank_width = self.blank_length * 0.75 # Convert character length to cm
2261
+ blank = f"#fillline(width: {blank_width}cm)"
2262
+
2263
+ label_part = f"{self.label}:" if self.label else ""
2264
+ unit_part = f" {self.unit}" if self.unit else ""
2265
+
2266
+ return f"{label_part} {blank}{unit_part}".strip()
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
+ )
2278
+
2279
+ @classmethod
2280
+ def multiple_choice(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
2281
+ """Create a multiple choice answer with wrong answer choices (baffles)"""
2282
+ return cls(
2283
+ value=value,
2284
+ kind=cls.CanvasAnswerKind.MULTIPLE_ANSWER,
2285
+ baffles=baffles,
2286
+ **kwargs
2287
+ )
2288
+
2289
+ # Static helper methods
2290
+ @staticmethod
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(
2319
+ value,
2320
+ *,
2321
+ allow_integer=True,
2322
+ allow_simple_fraction=True,
2323
+ max_denominator=720,
2324
+ allow_mixed=False,
2325
+ include_spaces=False,
2326
+ include_fixed_even_if_integer=False
2327
+ ):
2328
+ """Return a sorted list of strings you can paste into Canvas as alternate correct answers."""
2329
+ decimal.getcontext().prec = max(34, (ContentAST.Answer.DEFAULT_ROUNDING_DIGITS or 0) + 10)
2330
+ f = ContentAST.Answer._to_fraction(value)
2331
+ outs = set()
2332
+
2333
+ # Integer form
2334
+ if f.denominator == 1 and allow_integer:
2335
+ outs.add(str(f.numerator))
2336
+ if include_fixed_even_if_integer:
2337
+ q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2338
+ d = decimal.Decimal(f.numerator).quantize(q, rounding=decimal.ROUND_HALF_UP)
2339
+ outs.add(format(d, 'f'))
2340
+
2341
+ # Fixed-decimal form
2342
+ q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2343
+ d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2344
+ outs.add(format(d, 'f'))
2345
+
2346
+ # Trimmed decimal
2347
+ if ContentAST.Answer.DEFAULT_ROUNDING_DIGITS:
2348
+ q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2349
+ d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2350
+ s = format(d, 'f').rstrip('0').rstrip('.')
2351
+ if s.startswith('.'):
2352
+ s = '0' + s
2353
+ if s == '-0':
2354
+ s = '0'
2355
+ outs.add(s)
2356
+
2357
+ # Simple fraction
2358
+ if allow_simple_fraction:
2359
+ fr = f.limit_denominator(max_denominator)
2360
+ if fr == f:
2361
+ a, b = fr.numerator, fr.denominator
2362
+ outs.add(f"{a}/{b}")
2363
+ if include_spaces:
2364
+ outs.add(f"{a} / {b}")
2365
+ if allow_mixed and b != 1 and abs(a) > b:
2366
+ sign = '-' if a < 0 else ''
2367
+ A = abs(a)
2368
+ whole, rem = divmod(A, b)
2369
+ outs.add(f"{sign}{whole} {rem}/{b}")
2370
+
2371
+ return sorted(outs, key=lambda s: (len(s), s))
2372
+
2373
+ @staticmethod
2374
+ def fix_negative_zero(x):
2375
+ """Fix -0.0 display issue."""
2376
+ return 0.0 if x == 0 else x
2377
+
2378
+
2379
+ class AnswerTypes:
2380
+ # Multibase answers that can accept either hex, binary or decimal
2381
+ class MultiBase(ContentAST.Answer):
2382
+ """
2383
+ These are answers that can accept answers in any sort of format, and default to displaying in hex when written out.
2384
+ This will be the parent class for Binary, Hex, and Integer answers most likely.
2385
+ """
2386
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2387
+
2388
+ canvas_answers = [
2389
+ {
2390
+ "blank_id": self.key,
2391
+ "answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
2392
+ "answer_weight": 100 if self.correct else 0,
2393
+ },
2394
+ {
2395
+ "blank_id": self.key,
2396
+ "answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
2397
+ "answer_weight": 100 if self.correct else 0,
2398
+ },
2399
+ {
2400
+ "blank_id": self.key,
2401
+ "answer_text": f"{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
2402
+ "answer_weight": 100 if self.correct else 0,
2403
+ },
2404
+ {
2405
+ "blank_id": self.key,
2406
+ "answer_text": f"0x{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
2407
+ "answer_weight": 100 if self.correct else 0,
2408
+ },
2409
+ {
2410
+ "blank_id": self.key,
2411
+ "answer_text": f"{self.value}",
2412
+ "answer_weight": 100 if self.correct else 0,
2413
+ },
2414
+ ]
2415
+
2416
+ return canvas_answers
2417
+
2418
+ def get_display_string(self) -> str:
2419
+ # This is going to be the default for multi-base answers, but may change later.
2420
+ hex_digits = (self.length // 4) + 1 if self.length is not None else 0
2421
+ return f"0x{self.value:0{hex_digits}X}"
2422
+
2423
+ class Hex(MultiBase):
2424
+ pass
2425
+
2426
+ class Binary(MultiBase):
2427
+ def get_display_string(self) -> str:
2428
+ return f"0b{self.value:0{self.length if self.length is not None else 0}b}"
2429
+
2430
+ class Decimal(MultiBase):
2431
+ def get_display_string(self) -> str:
2432
+ return f"{self.value:0{self.length if self.length is not None else 0}}"
2433
+
2434
+ # Concrete type answers
2435
+ class Float(ContentAST.Answer):
2436
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2437
+ if single_answer:
2438
+ canvas_answers = [
2439
+ {
2440
+ "numerical_answer_type": "exact_answer",
2441
+ "answer_text": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
2442
+ "answer_exact": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
2443
+ "answer_error_margin": 0.1,
2444
+ "answer_weight": 100 if self.correct else 0,
2445
+ }
2446
+ ]
2447
+ else:
2448
+ # Use the accepted_strings helper
2449
+ answer_strings = ContentAST.Answer.accepted_strings(
2450
+ self.value,
2451
+ allow_integer=True,
2452
+ allow_simple_fraction=True,
2453
+ max_denominator=60,
2454
+ allow_mixed=True,
2455
+ include_spaces=False,
2456
+ include_fixed_even_if_integer=True
2457
+ )
2458
+
2459
+ canvas_answers = [
2460
+ {
2461
+ "blank_id": self.key,
2462
+ "answer_text": answer_string,
2463
+ "answer_weight": 100 if self.correct else 0,
2464
+ }
2465
+ for answer_string in answer_strings
2466
+ ]
2467
+ return canvas_answers
2468
+
2469
+ def get_display_string(self) -> str:
2470
+ rounded = round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2471
+ return f"{self.fix_negative_zero(rounded)}"
2472
+
2473
+ class Int(ContentAST.Answer):
2474
+
2475
+ # Canvas export methods (from misc.Answer)
2476
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2477
+ canvas_answers = [
2478
+ {
2479
+ "blank_id": self.key,
2480
+ "answer_text": str(int(self.value)),
2481
+ "answer_weight": 100 if self.correct else 0,
2482
+ }
2483
+ ]
2484
+ return canvas_answers
2485
+
2486
+ # Open Ended
2487
+ class OpenEnded(ContentAST.Answer):
2488
+ def __init__(self, *args, **kwargs):
2489
+ super().__init__(*args, **kwargs)
2490
+ self.kind=ContentAST.Answer.CanvasAnswerKind.ESSAY
2491
+
2492
+ class String(ContentAST.Answer):
2493
+ pass
2494
+
2495
+ class List(ContentAST.Answer):
2496
+ def __init__(self, order_matters=True, *args, **kwargs):
2497
+ super().__init__(*args, **kwargs)
2498
+ self.order_matters = order_matters
2499
+
2500
+ def get_for_canvas(self, single_answer=False):
2501
+ if self.order_matters:
2502
+ canvas_answers = [
2503
+ {
2504
+ "blank_id": self.key,
2505
+ "answer_text": ', '.join(map(str, self.value)),
2506
+ "answer_weight": 100 if self.correct else 0,
2507
+ },
2508
+ {
2509
+ "blank_id": self.key,
2510
+ "answer_text": ','.join(map(str, self.value)),
2511
+ "answer_weight": 100 if self.correct else 0,
2512
+ }
2513
+ ]
2514
+ else:
2515
+ canvas_answers = []
2516
+
2517
+ # With spaces
2518
+ canvas_answers.extend([
2519
+ {
2520
+ "blank_id": self.key,
2521
+ "answer_text": ', '.join(map(str, possible_state)),
2522
+ "answer_weight": 100 if self.correct else 0,
2523
+ }
2524
+ for possible_state in itertools.permutations(self.value)
2525
+ ])
2526
+
2527
+ # Without spaces
2528
+ canvas_answers.extend([
2529
+ {
2530
+ "blank_id": self.key,
2531
+ "answer_text": ','.join(map(str, possible_state)),
2532
+ "answer_weight": 100 if self.correct else 0,
2533
+ }
2534
+ for possible_state in itertools.permutations(self.value)
2535
+ ])
2536
+ return canvas_answers
2537
+
2538
+ def get_display_string(self) -> str:
2539
+ """Get the formatted display string for this answer (for grading/answer keys)."""
2540
+ return ", ".join(str(v) for v in self.value)
2541
+
2542
+ # Math types
2543
+ class Vector(ContentAST.Answer):
2544
+ """
2545
+ These are self-contained vectors that will go in a single answer block
2546
+ """
2547
+
2548
+ # Canvas export methods (from misc.Answer)
2549
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2550
+ # Get all answer variations
2551
+ answer_variations = [
2552
+ ContentAST.Answer.accepted_strings(dimension_value)
2553
+ for dimension_value in self.value
2554
+ ]
2555
+
2556
+ canvas_answers = []
2557
+ for combination in itertools.product(*answer_variations):
2558
+ # Add without anything surrounding
2559
+ canvas_answers.extend([
2560
+ {
2561
+ "blank_id": self.key,
2562
+ "answer_weight": 100 if self.correct else 0,
2563
+ "answer_text": f"{', '.join(combination)}",
2564
+ },
2565
+ {
2566
+ "blank_id": self.key,
2567
+ "answer_weight": 100 if self.correct else 0,
2568
+ "answer_text": f"{','.join(combination)}",
2569
+ },
2570
+ # Add parentheses format
2571
+ {
2572
+ "blank_id": self.key,
2573
+ "answer_weight": 100 if self.correct else 0,
2574
+ "answer_text": f"({', '.join(list(combination))})",
2575
+ },
2576
+ {
2577
+ "blank_id": self.key,
2578
+ "answer_weight": 100 if self.correct else 0,
2579
+ "answer_text": f"({','.join(list(combination))})",
2580
+ },
2581
+ # Add square brackets
2582
+ {
2583
+ "blank_id": self.key,
2584
+ "answer_weight": 100 if self.correct else 0,
2585
+ "answer_text": f"[{', '.join(list(combination))}]",
2586
+ },
2587
+ {
2588
+ "blank_id": self.key,
2589
+ "answer_weight": 100 if self.correct else 0,
2590
+ "answer_text": f"[{','.join(list(combination))}]",
2591
+ }
2592
+ ])
2593
+ return canvas_answers
2594
+
2595
+ def get_display_string(self) -> str:
2596
+ return ", ".join(
2597
+ str(self.fix_negative_zero(round(v, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS))).rstrip('0').rstrip('.') for v in self.value
2598
+ )
2599
+
2600
+ class CompoundAnswers(ContentAST.Answer):
2601
+ pass
2602
+ """
2603
+ Going forward, this might make a lot of sense to have a SubAnswer class that we can iterate over.
2604
+ We would convert into this shared format and just iterate over it whenever we need to.
2605
+ """
2606
+
2607
+ class Matrix(CompoundAnswers):
2608
+ """
2609
+ Matrix answers generate multiple blank_ids (e.g., M_0_0, M_0_1, M_1_0, M_1_1).
2610
+ """
2611
+
2612
+ def __init__(self, value, *args, **kwargs):
2613
+ super().__init__(value=value, *args, **kwargs)
2614
+
2615
+ self.data = [
2616
+ [
2617
+ AnswerTypes.Float(
2618
+ value=self.value[i, j],
2619
+ blank_length=5
2620
+ )
2621
+ for j in range(self.value.shape[1])
2622
+ ]
2623
+ for i in range(self.value.shape[0])
2624
+ ]
2625
+
2626
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2627
+ """Generate Canvas answers for each matrix element."""
2628
+ canvas_answers = []
2629
+
2630
+ for sub_answer in itertools.chain.from_iterable(self.data):
2631
+ canvas_answers.extend(sub_answer.get_for_canvas())
2632
+
2633
+ return canvas_answers
2634
+
2635
+ def render(self, *args, **kwargs) -> str:
2636
+ table = ContentAST.Table(self.data)
2637
+
2638
+ if self.label:
2639
+ return ContentAST.Container(
2640
+ [
2641
+ ContentAST.Text(f"{self.label} = "),
2642
+ table
2643
+ ]
2644
+ ).render(*args, **kwargs)
2645
+ return table.render(*args, **kwargs)
2646
+
1955
2647