QuizGenerator 0.4.4__py3-none-any.whl → 0.5.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 (31) hide show
  1. QuizGenerator/contentast.py +949 -80
  2. QuizGenerator/generate.py +44 -7
  3. QuizGenerator/misc.py +4 -554
  4. QuizGenerator/mixins.py +47 -25
  5. QuizGenerator/premade_questions/cst334/languages.py +139 -125
  6. QuizGenerator/premade_questions/cst334/math_questions.py +78 -66
  7. QuizGenerator/premade_questions/cst334/memory_questions.py +258 -144
  8. QuizGenerator/premade_questions/cst334/persistence_questions.py +71 -33
  9. QuizGenerator/premade_questions/cst334/process.py +51 -20
  10. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +32 -6
  11. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +59 -34
  12. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +27 -8
  13. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +53 -32
  14. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +228 -88
  15. QuizGenerator/premade_questions/cst463/models/attention.py +26 -10
  16. QuizGenerator/premade_questions/cst463/models/cnns.py +32 -19
  17. QuizGenerator/premade_questions/cst463/models/rnns.py +25 -12
  18. QuizGenerator/premade_questions/cst463/models/text.py +26 -11
  19. QuizGenerator/premade_questions/cst463/models/weight_counting.py +36 -22
  20. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +89 -109
  21. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +117 -51
  22. QuizGenerator/question.py +110 -15
  23. QuizGenerator/quiz.py +74 -23
  24. QuizGenerator/regenerate.py +98 -29
  25. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.0.dist-info}/METADATA +1 -1
  26. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.0.dist-info}/RECORD +29 -31
  27. QuizGenerator/README.md +0 -5
  28. QuizGenerator/logging.yaml +0 -55
  29. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.0.dist-info}/WHEEL +0 -0
  30. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.0.dist-info}/entry_points.txt +0 -0
  31. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -664,15 +664,19 @@ class ContentAST:
664
664
  - Organizing complex question content
665
665
 
666
666
  Example:
667
- def get_body(self):
667
+ def _get_body(self):
668
668
  body = ContentAST.Section()
669
+ answers = []
669
670
  body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
670
671
 
671
672
  matrix_data = [[1, 2], [3, 4]]
672
673
  body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
673
674
 
674
- body.add_element(ContentAST.Answer(answer=self.answer, label="Determinant"))
675
- return body
675
+ # Answer extends Leaf - add directly to body
676
+ ans = ContentAST.Answer.integer("det", self.determinant, label="Determinant")
677
+ answers.append(ans)
678
+ body.add_element(ans)
679
+ return body, answers
676
680
  """
677
681
  pass
678
682
 
@@ -729,23 +733,41 @@ class ContentAST:
729
733
  if self.hide_from_latex:
730
734
  return ""
731
735
 
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"""
736
+ # Extract code blocks, render them, and replace with placeholders
737
+ # This prevents the # escaping from affecting content inside code blocks
738
+ code_blocks = []
739
+
740
+ def save_code_block(match):
741
+ code_content = match.group(1).strip()
742
+ # Escape quotes for Typst raw() function
743
+ escaped_content = code_content.replace('"', r'\"')
744
+ rendered_block = f"""
736
745
  #box(
737
- raw("\1",
746
+ raw("{escaped_content}",
738
747
  block: true
739
748
  )
740
749
  )
741
- """,
750
+ """
751
+ placeholder = f"__CODE_BLOCK_{len(code_blocks)}__"
752
+ code_blocks.append(rendered_block)
753
+ return placeholder
754
+
755
+ # Replace code blocks with placeholders
756
+ content = re.sub(
757
+ r"```\s*(.*)\s*```",
758
+ save_code_block,
742
759
  self.content,
743
760
  flags=re.DOTALL
744
761
  )
745
762
 
746
- # In Typst, # starts code/function calls, so we need to escape it
763
+ # In Typst, # starts code/function calls, so we need to escape it in regular text
764
+ # (but not in code blocks, which are now placeholders)
747
765
  content = content.replace("# ", r"\# ")
748
-
766
+
767
+ # Restore code blocks
768
+ for i, block in enumerate(code_blocks):
769
+ content = content.replace(f"__CODE_BLOCK_{i}__", block)
770
+
749
771
  if self.emphasis:
750
772
  content = f"*{content}*"
751
773
  return content
@@ -903,8 +925,19 @@ class ContentAST:
903
925
  # Convert subscripts and superscripts from LaTeX to Typst
904
926
  # LaTeX uses braces: b_{out}, x_{10}, x^{2}
905
927
  # 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) # ^{...} -> ^(...)
928
+ # Multi-character text subscripts need quotes: L_{base} -> L_("base")
929
+ # But numbers don't: x_{10} -> x_(10)
930
+ def convert_sub_super(match):
931
+ content = match.group(1)
932
+ prefix = match.group(0)[0] # '_' or '^'
933
+ # If it's purely numeric or a single char, no quotes needed
934
+ if content.isdigit() or len(content) == 1:
935
+ return f'{prefix}({content})'
936
+ # If it's multi-char text, quote it
937
+ return f'{prefix}("{content}")'
938
+
939
+ latex_str = re.sub(r'_{([^}]+)}', convert_sub_super, latex_str)
940
+ latex_str = re.sub(r'\^{([^}]+)}', convert_sub_super, latex_str)
908
941
 
909
942
  # Convert LaTeX Greek letters to Typst syntax (remove backslash)
910
943
  greek_letters = [
@@ -922,19 +955,66 @@ class ContentAST:
922
955
  latex_str = latex_str.replace(r'\nabla', 'nabla')
923
956
  latex_str = latex_str.replace(r'\times', 'times')
924
957
  latex_str = latex_str.replace(r'\cdot', 'dot')
925
- latex_str = latex_str.replace(r'\partial', 'diff')
958
+ latex_str = latex_str.replace(r'\partial', 'partial')
959
+ latex_str = latex_str.replace(r'\sum', 'sum')
960
+ latex_str = latex_str.replace(r'\prod', 'product')
961
+ latex_str = latex_str.replace(r'\int', 'integral')
962
+ latex_str = latex_str.replace(r'\ln', 'ln')
963
+ latex_str = latex_str.replace(r'\log', 'log')
964
+ latex_str = latex_str.replace(r'\exp', 'exp')
965
+ latex_str = latex_str.replace(r'\sin', 'sin')
966
+ latex_str = latex_str.replace(r'\cos', 'cos')
967
+ latex_str = latex_str.replace(r'\tan', 'tan')
968
+ latex_str = latex_str.replace(r'\max', 'max')
969
+ latex_str = latex_str.replace(r'\min', 'min')
970
+ latex_str = latex_str.replace(r'\sqrt', 'sqrt')
971
+ # Convert \text{...} to "..." for Typst
972
+ latex_str = re.sub(r'\\text\{([^}]*)\}', r'"\1"', latex_str)
973
+ # Convert \frac{a}{b} to frac(a, b) for Typst
974
+ latex_str = re.sub(r'\\frac\{([^}]*)\}\{([^}]*)\}', r'frac(\1, \2)', latex_str)
975
+
976
+ # Handle matrix environments (bmatrix, pmatrix, vmatrix, Vmatrix, Bmatrix, matrix)
977
+ # Map bracket types to Typst delimiters
978
+ bracket_map = {
979
+ 'bmatrix': '\"[\"', # square brackets
980
+ 'pmatrix': '\"(\"', # parentheses (default)
981
+ 'vmatrix': '\"|\"', # single vertical bars (determinant)
982
+ 'Vmatrix': '\"||\"', # double vertical bars (norm)
983
+ 'Bmatrix': '\"{\"', # curly braces
984
+ 'matrix': None, # no brackets
985
+ }
986
+
987
+ for env_type, delim in bracket_map.items():
988
+ pattern = rf'\\begin\{{{env_type}\}}(.*?)\\end\{{{env_type}\}}'
989
+
990
+ def make_replacer(delimiter):
991
+ def replace_matrix(match):
992
+ content = match.group(1)
993
+ # Split rows by \\ and columns by &
994
+ rows = content.split(r'\\')
995
+ rows = [r.strip() for r in rows if r.strip()]
996
+
997
+ # Check if it's a vector (single column) or matrix
998
+ is_vector = all('&' not in row for row in rows)
999
+
1000
+ if is_vector:
1001
+ # Single column - use semicolons to separate rows in mat()
1002
+ elements = "; ".join(rows)
1003
+ else:
1004
+ # Multiple columns - replace & with , and rows with ;
1005
+ formatted_rows = [row.replace('&', ',').strip() for row in rows]
1006
+ elements = "; ".join(formatted_rows)
926
1007
 
927
- # Handle matrix environments
928
- if r'\begin{matrix}' in latex_str:
929
- matrix_pattern = r'\[\\begin\{matrix\}(.*?)\\end\{matrix\}\]'
1008
+ if delimiter:
1009
+ return f"mat(delim: {delimiter}, {elements})"
1010
+ else:
1011
+ return f"mat({elements})"
1012
+ return replace_matrix
930
1013
 
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)})"
1014
+ latex_str = re.sub(pattern, make_replacer(delim), latex_str, flags=re.DOTALL)
936
1015
 
937
- latex_str = re.sub(matrix_pattern, replace_matrix, latex_str)
1016
+ # Handle \left\| ... \right\| for norms
1017
+ latex_str = re.sub(r'\\\|', '||', latex_str)
938
1018
 
939
1019
  return latex_str
940
1020
 
@@ -955,6 +1035,125 @@ class ContentAST:
955
1035
 
956
1036
  return cls('\n'.join(equation_lines))
957
1037
 
1038
+ class MathExpression(Leaf):
1039
+ """
1040
+ Compose multiple math elements into a single format-independent expression.
1041
+
1042
+ This allows mixing ContentAST elements (like Matrix) with math operators
1043
+ and symbols, with each part rendering appropriately for the target format.
1044
+
1045
+ Example:
1046
+ # Vector magnitude: ||v|| =
1047
+ body.add_element(ContentAST.MathExpression([
1048
+ "||",
1049
+ ContentAST.Matrix(data=[[1], [2], [3]], bracket_type="b"),
1050
+ "|| = "
1051
+ ]))
1052
+
1053
+ # Vector addition: a + b =
1054
+ body.add_element(ContentAST.MathExpression([
1055
+ ContentAST.Matrix(data=[[1], [2]], bracket_type="b"),
1056
+ " + ",
1057
+ ContentAST.Matrix(data=[[3], [4]], bracket_type="b"),
1058
+ " = "
1059
+ ]))
1060
+
1061
+ Parts can be:
1062
+ - Strings: rendered as-is (math operators, symbols, etc.)
1063
+ - ContentAST elements: call their render method for the target format
1064
+ """
1065
+ def __init__(self, parts, inline=False):
1066
+ super().__init__("[math_expression]")
1067
+ self.parts = parts # List of strings and/or ContentAST elements
1068
+ self.inline = inline
1069
+
1070
+ def _render_parts(self, output_format, **kwargs):
1071
+ """Render all parts for the given output format."""
1072
+ rendered = []
1073
+ for part in self.parts:
1074
+ if isinstance(part, str):
1075
+ # Convert LaTeX operators to Typst if needed
1076
+ if output_format == ContentAST.OutputFormat.TYPST:
1077
+ part = part.replace(r'\cdot', ' dot ')
1078
+ part = part.replace(r'\times', ' times ')
1079
+ part = part.replace(r'\div', ' div ')
1080
+ rendered.append(part)
1081
+ elif isinstance(part, ContentAST.Element):
1082
+ # Use dedicated math_content methods if available (cleaner than stripping delimiters)
1083
+ if output_format == ContentAST.OutputFormat.HTML:
1084
+ # For HTML (MathJax), use LaTeX math content
1085
+ if hasattr(part, 'math_content_latex'):
1086
+ rendered.append(part.math_content_latex())
1087
+ else:
1088
+ # Fallback: try to extract from render_html
1089
+ html = part.render_html(**kwargs)
1090
+ html = re.sub(r"<[^>]+>", "", html)
1091
+ html = re.sub(r"^\$\$?\s*\\displaystyle\s*", "", html)
1092
+ html = re.sub(r"^\$\$?\s*", "", html)
1093
+ html = re.sub(r"\s*\$\$?$", "", html)
1094
+ rendered.append(html)
1095
+ elif output_format == ContentAST.OutputFormat.TYPST:
1096
+ if hasattr(part, 'math_content_typst'):
1097
+ rendered.append(part.math_content_typst())
1098
+ else:
1099
+ # Fallback: try to extract from render_typst
1100
+ typst = part.render_typst(**kwargs)
1101
+ typst = re.sub(r"^\s*\$\s*", "", typst)
1102
+ typst = re.sub(r"\s*\$\s*$", "", typst)
1103
+ rendered.append(typst)
1104
+ elif output_format == ContentAST.OutputFormat.LATEX:
1105
+ if hasattr(part, 'math_content_latex'):
1106
+ rendered.append(part.math_content_latex())
1107
+ else:
1108
+ latex = part.render_latex(**kwargs)
1109
+ latex = re.sub(r"^\\begin\{flushleft\}", "", latex)
1110
+ latex = re.sub(r"\\end\{flushleft\}$", "", latex)
1111
+ latex = re.sub(r"^\\\[", "", latex)
1112
+ latex = re.sub(r"\\\]$", "", latex)
1113
+ latex = re.sub(r"^\$", "", latex)
1114
+ latex = re.sub(r"\$~?$", "", latex)
1115
+ rendered.append(latex)
1116
+ elif output_format == ContentAST.OutputFormat.MARKDOWN:
1117
+ if hasattr(part, 'math_content_latex'):
1118
+ rendered.append(part.math_content_latex())
1119
+ else:
1120
+ md = part.render_markdown(**kwargs)
1121
+ md = re.sub(r"^\$", "", md)
1122
+ md = re.sub(r"\$$", "", md)
1123
+ rendered.append(md)
1124
+ else:
1125
+ # Convert to string as fallback
1126
+ rendered.append(str(part))
1127
+ return "".join(rendered)
1128
+
1129
+ def render_markdown(self, **kwargs):
1130
+ content = self._render_parts(ContentAST.OutputFormat.MARKDOWN, **kwargs)
1131
+ if self.inline:
1132
+ return f"${content}$"
1133
+ else:
1134
+ return f"$\\displaystyle {content}$"
1135
+
1136
+ def render_html(self, **kwargs):
1137
+ content = self._render_parts(ContentAST.OutputFormat.HTML, **kwargs)
1138
+ if self.inline:
1139
+ return f"\\({content}\\)"
1140
+ else:
1141
+ return f"<div class='math'>$$ \\displaystyle {content} \\; $$</div>"
1142
+
1143
+ def render_latex(self, **kwargs):
1144
+ content = self._render_parts(ContentAST.OutputFormat.LATEX, **kwargs)
1145
+ if self.inline:
1146
+ return f"${content}$~"
1147
+ else:
1148
+ return f"\\begin{{flushleft}}${content}$\\end{{flushleft}}"
1149
+
1150
+ def render_typst(self, **kwargs):
1151
+ content = self._render_parts(ContentAST.OutputFormat.TYPST, **kwargs)
1152
+ if self.inline:
1153
+ return f"${content}$"
1154
+ else:
1155
+ return f" $ {content} $ "
1156
+
958
1157
  class Matrix(Leaf):
959
1158
  """
960
1159
  Mathematical matrix renderer for consistent cross-format display.
@@ -1038,6 +1237,42 @@ class ContentAST:
1038
1237
  matrix_content = r" \\ ".join(rows)
1039
1238
  return f"\\begin{{{bracket_type}matrix}} {matrix_content} \\end{{{bracket_type}matrix}}"
1040
1239
 
1240
+ def math_content_latex(self):
1241
+ """Return raw LaTeX math content without delimiters (for use in MathExpression)."""
1242
+ return ContentAST.Matrix.to_latex(self.data, self.bracket_type)
1243
+
1244
+ def math_content_typst(self):
1245
+ """Return raw Typst math content without delimiters (for use in MathExpression)."""
1246
+ # Build matrix content
1247
+ rows = []
1248
+ for row in self.data:
1249
+ rows.append(", ".join(str(cell) for cell in row))
1250
+
1251
+ # Check if it's a vector (single column)
1252
+ is_vector = all(len(row) == 1 for row in self.data)
1253
+
1254
+ if is_vector:
1255
+ # Use vec() for vectors
1256
+ matrix_content = ", ".join(str(row[0]) for row in self.data)
1257
+ result = f"vec({matrix_content})"
1258
+ else:
1259
+ # Use mat() for matrices with semicolons separating rows
1260
+ matrix_content = "; ".join(rows)
1261
+ result = f"mat({matrix_content})"
1262
+
1263
+ # Add bracket delimiters if needed
1264
+ if self.bracket_type == "b": # square brackets
1265
+ matrix_content_inner = ", ".join(str(row[0]) for row in self.data) if is_vector else "; ".join(rows)
1266
+ result = f"mat(delim: \"[\", {matrix_content_inner})" if not is_vector else f"vec(delim: \"[\", {matrix_content_inner})"
1267
+ elif self.bracket_type == "v": # vertical bars (determinant)
1268
+ result = f"mat(delim: \"|\", {'; '.join(rows)})"
1269
+ elif self.bracket_type == "V": # double vertical bars (norm)
1270
+ result = f"mat(delim: \"||\", {'; '.join(rows)})"
1271
+ elif self.bracket_type == "B": # curly braces
1272
+ result = f"mat(delim: \"{{\", {'; '.join(rows)})"
1273
+
1274
+ return result
1275
+
1041
1276
  def render_markdown(self, **kwargs):
1042
1277
  matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1043
1278
  rows = []
@@ -1233,79 +1468,716 @@ class ContentAST:
1233
1468
 
1234
1469
  class Answer(Leaf):
1235
1470
  """
1236
- Answer input field that renders as blanks in PDF and shows answers in HTML.
1471
+ Unified answer class combining data storage, Canvas export, and rendering.
1472
+
1473
+ Extends ContentAST.Leaf to integrate seamlessly with the ContentAST tree while
1474
+ maintaining all Canvas export functionality.
1237
1475
 
1238
1476
  CRITICAL: Use this for ALL answer inputs in questions.
1239
1477
  Creates appropriate input fields that work across both PDF and Canvas formats.
1240
1478
  In PDF, renders as blank lines for students to fill in.
1241
1479
  In HTML/Canvas, can display the answer for checking.
1242
1480
 
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
1481
  Example:
1249
1482
  # 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
- ))
1483
+ ans = ContentAST.Answer.integer("result", 42, label="Result", unit="MB")
1484
+ body.add_element(ans)
1485
+ answers.append(ans) # Track for Canvas export
1261
1486
  """
1262
-
1263
- def __init__(self, answer, label: str = "", unit: str = "", blank_length=5):
1264
- super().__init__(label)
1265
- self.answer = answer
1487
+
1488
+ DEFAULT_ROUNDING_DIGITS = 4
1489
+
1490
+ class AnswerKind(enum.Enum):
1491
+ BLANK = "fill_in_multiple_blanks_question"
1492
+ MULTIPLE_ANSWER = "multiple_answers_question"
1493
+ ESSAY = "essay_question"
1494
+ MULTIPLE_DROPDOWN = "multiple_dropdowns_question"
1495
+ NUMERICAL_QUESTION = "numerical_question"
1496
+
1497
+ class VariableKind(enum.Enum):
1498
+ STR = enum.auto()
1499
+ INT = enum.auto()
1500
+ FLOAT = enum.auto()
1501
+ BINARY = enum.auto()
1502
+ HEX = enum.auto()
1503
+ BINARY_OR_HEX = enum.auto()
1504
+ AUTOFLOAT = enum.auto()
1505
+ LIST = enum.auto()
1506
+ VECTOR = enum.auto()
1507
+ MATRIX = enum.auto()
1508
+
1509
+ def __init__(
1510
+ self,
1511
+ key=None, # Can be str (new pattern) or Answer object (old wrapper pattern)
1512
+ value=None,
1513
+ kind: 'ContentAST.Answer.AnswerKind' = None,
1514
+ variable_kind: 'ContentAST.Answer.VariableKind' = None,
1515
+ # Data fields (from misc.Answer)
1516
+ display=None,
1517
+ length=None,
1518
+ correct=True,
1519
+ baffles=None,
1520
+ pdf_only=False,
1521
+ # Rendering fields (from ContentAST.Answer)
1522
+ label: str = "",
1523
+ unit: str = "",
1524
+ blank_length=5,
1525
+ # Backward compatibility for old wrapper pattern
1526
+ answer=None # Old pattern: ContentAST.Answer(answer=misc_answer_obj)
1527
+ ):
1528
+ # BACKWARD COMPATIBILITY: Handle old wrapper pattern
1529
+ # Old: ContentAST.Answer(Answer.string("key", "value"))
1530
+ # Old: ContentAST.Answer(answer=some_answer_obj, label="...")
1531
+ if answer is not None or (key is not None and isinstance(key, ContentAST.Answer)):
1532
+ # Old wrapper pattern detected
1533
+ wrapped_answer = answer if answer is not None else key
1534
+
1535
+ if wrapped_answer is None:
1536
+ raise ValueError("Must provide either 'key' and 'value', or 'answer' parameter")
1537
+
1538
+ # Copy all fields from wrapped answer
1539
+ super().__init__(content=label if label else wrapped_answer.label)
1540
+ self.key = wrapped_answer.key
1541
+ self.value = wrapped_answer.value
1542
+ self.kind = wrapped_answer.kind
1543
+ self.variable_kind = wrapped_answer.variable_kind
1544
+ self.display = wrapped_answer.display
1545
+ self.length = wrapped_answer.length
1546
+ self.correct = wrapped_answer.correct
1547
+ self.baffles = wrapped_answer.baffles
1548
+ self.pdf_only = wrapped_answer.pdf_only
1549
+
1550
+ # Use provided rendering fields or copy from wrapped answer
1551
+ self.label = label if label else wrapped_answer.label
1552
+ self.unit = unit if unit else (wrapped_answer.unit if hasattr(wrapped_answer, 'unit') else "")
1553
+ self.blank_length = blank_length if blank_length != 5 else (wrapped_answer.blank_length if hasattr(wrapped_answer, 'blank_length') else 5)
1554
+ return
1555
+
1556
+ # NEW PATTERN: Normal construction
1557
+ if key is None:
1558
+ raise ValueError("Must provide 'key' parameter for new Answer pattern, or 'answer' parameter for old wrapper pattern")
1559
+
1560
+ # Initialize Leaf with label as content
1561
+ super().__init__(content=label if label else "")
1562
+
1563
+ # Data fields
1564
+ self.key = key
1565
+ self.value = value
1566
+ self.kind = kind if kind is not None else ContentAST.Answer.AnswerKind.BLANK
1567
+ self.variable_kind = variable_kind if variable_kind is not None else ContentAST.Answer.VariableKind.STR
1568
+
1569
+ # For list values in display, show the first option (or join them with /)
1570
+ if display is not None:
1571
+ self.display = display
1572
+ elif isinstance(value, list) and self.variable_kind == ContentAST.Answer.VariableKind.STR:
1573
+ self.display = value[0] if len(value) == 1 else " / ".join(value)
1574
+ else:
1575
+ self.display = value
1576
+
1577
+ self.length = length # Used for bits and hex to be printed appropriately
1578
+ self.correct = correct
1579
+ self.baffles = baffles
1580
+ self.pdf_only = pdf_only
1581
+
1582
+ # Rendering fields
1266
1583
  self.label = label
1267
1584
  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
1585
+ self.blank_length = blank_length
1586
+
1587
+ # Canvas export methods (from misc.Answer)
1588
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
1589
+ """Generate Canvas answer dictionaries based on variable_kind."""
1590
+ import itertools
1591
+ import math
1592
+ import decimal
1593
+ import fractions
1594
+
1595
+ # If this answer is PDF-only, don't send it to Canvas
1596
+ if self.pdf_only:
1597
+ return []
1598
+
1599
+ canvas_answers = []
1600
+
1601
+ if self.variable_kind == ContentAST.Answer.VariableKind.BINARY:
1602
+ canvas_answers = [
1603
+ {
1604
+ "blank_id": self.key,
1605
+ "answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
1606
+ "answer_weight": 100 if self.correct else 0,
1607
+ },
1608
+ {
1609
+ "blank_id": self.key,
1610
+ "answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
1611
+ "answer_weight": 100 if self.correct else 0,
1612
+ }
1613
+ ]
1614
+
1615
+ elif self.variable_kind == ContentAST.Answer.VariableKind.HEX:
1616
+ canvas_answers = [
1617
+ {
1618
+ "blank_id": self.key,
1619
+ "answer_text": f"{self.value:0{(self.length // 8) + 1 if self.length is not None else 0}X}",
1620
+ "answer_weight": 100 if self.correct else 0,
1621
+ },
1622
+ {
1623
+ "blank_id": self.key,
1624
+ "answer_text": f"0x{self.value:0{(self.length // 8) + 1 if self.length is not None else 0}X}",
1625
+ "answer_weight": 100 if self.correct else 0,
1626
+ }
1627
+ ]
1628
+
1629
+ elif self.variable_kind == ContentAST.Answer.VariableKind.BINARY_OR_HEX:
1630
+ canvas_answers = [
1631
+ {
1632
+ "blank_id": self.key,
1633
+ "answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
1634
+ "answer_weight": 100 if self.correct else 0,
1635
+ },
1636
+ {
1637
+ "blank_id": self.key,
1638
+ "answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
1639
+ "answer_weight": 100 if self.correct else 0,
1640
+ },
1641
+ {
1642
+ "blank_id": self.key,
1643
+ "answer_text": f"{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
1644
+ "answer_weight": 100 if self.correct else 0,
1645
+ },
1646
+ {
1647
+ "blank_id": self.key,
1648
+ "answer_text": f"0x{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
1649
+ "answer_weight": 100 if self.correct else 0,
1650
+ },
1651
+ {
1652
+ "blank_id": self.key,
1653
+ "answer_text": f"{self.value}",
1654
+ "answer_weight": 100 if self.correct else 0,
1655
+ },
1656
+ ]
1657
+
1658
+ elif self.variable_kind in [
1659
+ ContentAST.Answer.VariableKind.AUTOFLOAT,
1660
+ ContentAST.Answer.VariableKind.FLOAT,
1661
+ ContentAST.Answer.VariableKind.INT
1662
+ ]:
1663
+ if single_answer:
1664
+ canvas_answers = [
1665
+ {
1666
+ "numerical_answer_type": "exact_answer",
1667
+ "answer_text": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
1668
+ "answer_exact": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
1669
+ "answer_error_margin": 0.1,
1670
+ "answer_weight": 100 if self.correct else 0,
1671
+ }
1672
+ ]
1673
+ else:
1674
+ # Use the accepted_strings helper
1675
+ answer_strings = ContentAST.Answer.accepted_strings(
1676
+ self.value,
1677
+ allow_integer=True,
1678
+ allow_simple_fraction=True,
1679
+ max_denominator=60,
1680
+ allow_mixed=True,
1681
+ include_spaces=False,
1682
+ include_fixed_even_if_integer=True
1683
+ )
1684
+
1685
+ canvas_answers = [
1686
+ {
1687
+ "blank_id": self.key,
1688
+ "answer_text": answer_string,
1689
+ "answer_weight": 100 if self.correct else 0,
1690
+ }
1691
+ for answer_string in answer_strings
1692
+ ]
1693
+
1694
+ elif self.variable_kind == ContentAST.Answer.VariableKind.VECTOR:
1695
+ # Get all answer variations
1696
+ answer_variations = [
1697
+ ContentAST.Answer.accepted_strings(dimension_value)
1698
+ for dimension_value in self.value
1699
+ ]
1700
+
1701
+ canvas_answers = []
1702
+ for combination in itertools.product(*answer_variations):
1703
+ # Add parentheses format
1704
+ canvas_answers.append({
1705
+ "blank_id": self.key,
1706
+ "answer_weight": 100 if self.correct else 0,
1707
+ "answer_text": f"({', '.join(list(combination))})",
1708
+ })
1709
+
1710
+ # Add non-parentheses format for single-element vectors
1711
+ if len(combination) == 1:
1712
+ canvas_answers.append({
1713
+ "blank_id": self.key,
1714
+ "answer_weight": 100 if self.correct else 0,
1715
+ "answer_text": f"{', '.join(combination)}",
1716
+ })
1717
+ return canvas_answers
1718
+
1719
+ elif self.variable_kind == ContentAST.Answer.VariableKind.LIST:
1720
+ canvas_answers = [
1721
+ {
1722
+ "blank_id": self.key,
1723
+ "answer_text": ', '.join(map(str, possible_state)),
1724
+ "answer_weight": 100 if self.correct else 0,
1725
+ }
1726
+ for possible_state in [self.value]
1727
+ ]
1728
+
1273
1729
  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
-
1730
+ # For string answers, check if value is a list of acceptable alternatives
1731
+ if isinstance(self.value, list):
1732
+ canvas_answers = [
1733
+ {
1734
+ "blank_id": self.key,
1735
+ "answer_text": str(alt),
1736
+ "answer_weight": 100 if self.correct else 0,
1737
+ }
1738
+ for alt in self.value
1739
+ ]
1740
+ else:
1741
+ canvas_answers = [{
1742
+ "blank_id": self.key,
1743
+ "answer_text": self.value,
1744
+ "answer_weight": 100 if self.correct else 0,
1745
+ }]
1746
+
1747
+ # Add baffles (incorrect answer choices)
1748
+ if self.baffles is not None:
1749
+ for baffle in self.baffles:
1750
+ canvas_answers.append({
1751
+ "blank_id": self.key,
1752
+ "answer_text": baffle,
1753
+ "answer_weight": 0,
1754
+ })
1755
+
1756
+ return canvas_answers
1757
+
1758
+ def get_display_string(self) -> str:
1759
+ """Get the formatted display string for this answer (for grading/answer keys)."""
1760
+ import math
1761
+
1762
+ def fix_negative_zero(x):
1763
+ """Fix -0.0 display issue."""
1764
+ return 0.0 if x == 0 else x
1765
+
1766
+ if self.variable_kind == ContentAST.Answer.VariableKind.BINARY_OR_HEX:
1767
+ hex_digits = math.ceil(self.length / 4) if self.length is not None else 0
1768
+ return f"0x{self.value:0{hex_digits}X}"
1769
+
1770
+ elif self.variable_kind == ContentAST.Answer.VariableKind.BINARY:
1771
+ return f"0b{self.value:0{self.length if self.length is not None else 0}b}"
1772
+
1773
+ elif self.variable_kind == ContentAST.Answer.VariableKind.HEX:
1774
+ hex_digits = (self.length // 4) + 1 if self.length is not None else 0
1775
+ return f"0x{self.value:0{hex_digits}X}"
1776
+
1777
+ elif self.variable_kind == ContentAST.Answer.VariableKind.AUTOFLOAT:
1778
+ rounded = round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
1779
+ return f"{fix_negative_zero(rounded)}"
1780
+
1781
+ elif self.variable_kind == ContentAST.Answer.VariableKind.FLOAT:
1782
+ if isinstance(self.value, (list, tuple)):
1783
+ rounded = round(self.value[0], ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
1784
+ return f"{fix_negative_zero(rounded)}"
1785
+ rounded = round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
1786
+ return f"{fix_negative_zero(rounded)}"
1787
+
1788
+ elif self.variable_kind == ContentAST.Answer.VariableKind.INT:
1789
+ return str(int(self.value))
1790
+
1791
+ elif self.variable_kind == ContentAST.Answer.VariableKind.LIST:
1792
+ return ", ".join(str(v) for v in self.value)
1793
+
1794
+ elif self.variable_kind == ContentAST.Answer.VariableKind.VECTOR:
1795
+ def fix_negative_zero(x):
1796
+ return 0.0 if x == 0 else x
1797
+ return ", ".join(str(fix_negative_zero(round(v, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS))) for v in self.value)
1798
+
1799
+ else:
1800
+ return str(self.display if hasattr(self, 'display') else self.value)
1801
+
1802
+ # Rendering methods (override Leaf's defaults)
1803
+ def render_markdown(self, **kwargs):
1804
+ return f"{self.label + (':' if len(self.label) > 0 else '')} [{self.key}] {self.unit}".strip()
1805
+
1277
1806
  def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
1278
1807
  if can_be_numerical:
1279
1808
  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
-
1809
+ if show_answers:
1810
+ answer_display = self.get_display_string()
1287
1811
  label_part = f"{self.label}:" if self.label else ""
1288
1812
  unit_part = f" {self.unit}" if self.unit else ""
1289
1813
  return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
1290
1814
  else:
1291
- # Default behavior: show [key]
1292
1815
  return self.render_markdown(**kwargs)
1293
-
1816
+
1294
1817
  def render_latex(self, **kwargs):
1295
- return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.length}}} {self.unit}".strip()
1296
-
1818
+ return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.blank_length}}} {self.unit}".strip()
1819
+
1297
1820
  def render_typst(self, **kwargs):
1298
1821
  """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
1822
+ blank_width = self.blank_length * 0.75 # Convert character length to cm
1302
1823
  blank = f"#fillline(width: {blank_width}cm)"
1303
-
1824
+
1304
1825
  label_part = f"{self.label}:" if self.label else ""
1305
1826
  unit_part = f" {self.unit}" if self.unit else ""
1306
-
1827
+
1307
1828
  return f"{label_part} {blank}{unit_part}".strip()
1308
-
1829
+
1830
+ # Factory methods for common answer types
1831
+ @classmethod
1832
+ def binary_hex(cls, key: str, value: int, length: int = None, **kwargs) -> 'ContentAST.Answer':
1833
+ """Create an answer that accepts binary or hex format"""
1834
+ return cls(
1835
+ key=key,
1836
+ value=value,
1837
+ variable_kind=cls.VariableKind.BINARY_OR_HEX,
1838
+ length=length,
1839
+ **kwargs
1840
+ )
1841
+
1842
+ @classmethod
1843
+ def auto_float(cls, key: str, value: float, **kwargs) -> 'ContentAST.Answer':
1844
+ """Create an answer that accepts multiple float formats (decimal, fraction, mixed)"""
1845
+ return cls(
1846
+ key=key,
1847
+ value=value,
1848
+ variable_kind=cls.VariableKind.AUTOFLOAT,
1849
+ **kwargs
1850
+ )
1851
+
1852
+ @classmethod
1853
+ def integer(cls, key: str, value: int, **kwargs) -> 'ContentAST.Answer':
1854
+ """Create an integer answer"""
1855
+ return cls(
1856
+ key=key,
1857
+ value=value,
1858
+ variable_kind=cls.VariableKind.INT,
1859
+ **kwargs
1860
+ )
1861
+
1862
+ @classmethod
1863
+ def string(cls, key: str, value: str, **kwargs) -> 'ContentAST.Answer':
1864
+ """Create a string answer"""
1865
+ return cls(
1866
+ key=key,
1867
+ value=value,
1868
+ variable_kind=cls.VariableKind.STR,
1869
+ **kwargs
1870
+ )
1871
+
1872
+ @classmethod
1873
+ def binary(cls, key: str, value: int, length: int = None, **kwargs) -> 'ContentAST.Answer':
1874
+ """Create a binary-only answer"""
1875
+ return cls(
1876
+ key=key,
1877
+ value=value,
1878
+ variable_kind=cls.VariableKind.BINARY,
1879
+ length=length,
1880
+ **kwargs
1881
+ )
1882
+
1883
+ @classmethod
1884
+ def hex_value(cls, key: str, value: int, length: int = None, **kwargs) -> 'ContentAST.Answer':
1885
+ """Create a hex-only answer"""
1886
+ return cls(
1887
+ key=key,
1888
+ value=value,
1889
+ variable_kind=cls.VariableKind.HEX,
1890
+ length=length,
1891
+ **kwargs
1892
+ )
1893
+
1894
+ @classmethod
1895
+ def float_value(cls, key: str, value, **kwargs) -> 'ContentAST.Answer':
1896
+ """Create a simple float answer (no fraction conversion)"""
1897
+ return cls(
1898
+ key=key,
1899
+ value=value,
1900
+ variable_kind=cls.VariableKind.FLOAT,
1901
+ **kwargs
1902
+ )
1903
+
1904
+ @classmethod
1905
+ def list_value(cls, key: str, value: list, **kwargs) -> 'ContentAST.Answer':
1906
+ """Create a list answer (comma-separated values)"""
1907
+ return cls(
1908
+ key=key,
1909
+ value=value,
1910
+ variable_kind=cls.VariableKind.LIST,
1911
+ **kwargs
1912
+ )
1913
+
1914
+ @classmethod
1915
+ def vector_value(cls, key: str, value: List[float], **kwargs) -> 'ContentAST.Answer':
1916
+ """Create a vector answer"""
1917
+ return cls(
1918
+ key=key,
1919
+ value=value,
1920
+ variable_kind=cls.VariableKind.VECTOR,
1921
+ **kwargs
1922
+ )
1923
+
1924
+ @classmethod
1925
+ def dropdown(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
1926
+ """Create a dropdown answer with wrong answer choices (baffles)"""
1927
+ return cls(
1928
+ key=key,
1929
+ value=value,
1930
+ kind=cls.AnswerKind.MULTIPLE_DROPDOWN,
1931
+ baffles=baffles,
1932
+ **kwargs
1933
+ )
1934
+
1935
+ @classmethod
1936
+ def multiple_choice(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
1937
+ """Create a multiple choice answer with wrong answer choices (baffles)"""
1938
+ return cls(
1939
+ key=key,
1940
+ value=value,
1941
+ kind=cls.AnswerKind.MULTIPLE_ANSWER,
1942
+ baffles=baffles,
1943
+ **kwargs
1944
+ )
1945
+
1946
+ @classmethod
1947
+ def essay(cls, key: str, **kwargs) -> 'ContentAST.Answer':
1948
+ """Create an essay question (no specific correct answer)"""
1949
+ return cls(
1950
+ key=key,
1951
+ value="", # Essays don't have predetermined answers
1952
+ kind=cls.AnswerKind.ESSAY,
1953
+ **kwargs
1954
+ )
1955
+
1956
+ @classmethod
1957
+ def matrix(cls, key: str, value, **kwargs):
1958
+ """Create a matrix answer (returns MatrixAnswer instance)"""
1959
+ return ContentAST.MatrixAnswer(
1960
+ key=key,
1961
+ value=value,
1962
+ variable_kind=cls.VariableKind.MATRIX,
1963
+ **kwargs
1964
+ )
1965
+
1966
+ # Static helper methods
1967
+ @staticmethod
1968
+ def _to_fraction(x):
1969
+ """Convert int/float/decimal.Decimal/fractions.Fraction/str to fractions.Fraction exactly."""
1970
+ import fractions
1971
+ import decimal
1972
+
1973
+ if isinstance(x, fractions.Fraction):
1974
+ return x
1975
+ if isinstance(x, int):
1976
+ return fractions.Fraction(x, 1)
1977
+ if isinstance(x, decimal.Decimal):
1978
+ # exact conversion of decimal.Decimal to fractions.Fraction
1979
+ sign, digits, exp = x.as_tuple()
1980
+ n = 0
1981
+ for d in digits:
1982
+ n = n * 10 + d
1983
+ n = -n if sign else n
1984
+ if exp >= 0:
1985
+ return fractions.Fraction(n * (10 ** exp), 1)
1986
+ else:
1987
+ return fractions.Fraction(n, 10 ** (-exp))
1988
+ if isinstance(x, str):
1989
+ s = x.strip()
1990
+ if '/' in s:
1991
+ a, b = s.split('/', 1)
1992
+ return fractions.Fraction(int(a.strip()), int(b.strip()))
1993
+ return fractions.Fraction(decimal.Decimal(s))
1994
+ # float or other numerics
1995
+ return fractions.Fraction(decimal.Decimal(str(x)))
1996
+
1997
+ @staticmethod
1998
+ def accepted_strings(
1999
+ value,
2000
+ *,
2001
+ allow_integer=True,
2002
+ allow_simple_fraction=True,
2003
+ max_denominator=720,
2004
+ allow_mixed=False,
2005
+ include_spaces=False,
2006
+ include_fixed_even_if_integer=False
2007
+ ):
2008
+ """Return a sorted list of strings you can paste into Canvas as alternate correct answers."""
2009
+ import decimal
2010
+ import fractions
2011
+
2012
+ decimal.getcontext().prec = max(34, (ContentAST.Answer.DEFAULT_ROUNDING_DIGITS or 0) + 10)
2013
+ f = ContentAST.Answer._to_fraction(value)
2014
+ outs = set()
2015
+
2016
+ # Integer form
2017
+ if f.denominator == 1 and allow_integer:
2018
+ outs.add(str(f.numerator))
2019
+ if include_fixed_even_if_integer:
2020
+ q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2021
+ d = decimal.Decimal(f.numerator).quantize(q, rounding=decimal.ROUND_HALF_UP)
2022
+ outs.add(format(d, 'f'))
2023
+
2024
+ # Fixed-decimal form
2025
+ q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2026
+ d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2027
+ outs.add(format(d, 'f'))
2028
+
2029
+ # Trimmed decimal
2030
+ if ContentAST.Answer.DEFAULT_ROUNDING_DIGITS:
2031
+ q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2032
+ d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2033
+ s = format(d, 'f').rstrip('0').rstrip('.')
2034
+ if s.startswith('.'):
2035
+ s = '0' + s
2036
+ if s == '-0':
2037
+ s = '0'
2038
+ outs.add(s)
2039
+
2040
+ # Simple fraction
2041
+ if allow_simple_fraction:
2042
+ fr = f.limit_denominator(max_denominator)
2043
+ if fr == f:
2044
+ a, b = fr.numerator, fr.denominator
2045
+ outs.add(f"{a}/{b}")
2046
+ if include_spaces:
2047
+ outs.add(f"{a} / {b}")
2048
+ if allow_mixed and b != 1 and abs(a) > b:
2049
+ sign = '-' if a < 0 else ''
2050
+ A = abs(a)
2051
+ whole, rem = divmod(A, b)
2052
+ outs.add(f"{sign}{whole} {rem}/{b}")
2053
+
2054
+ return sorted(outs, key=lambda s: (len(s), s))
2055
+
2056
+ class MatrixAnswer(Answer):
2057
+ """
2058
+ Matrix answers generate multiple blank_ids (e.g., M_0_0, M_0_1, M_1_0, M_1_1).
2059
+ """
2060
+
2061
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2062
+ """Generate Canvas answers for each matrix element."""
2063
+ import numpy as np
2064
+
2065
+ canvas_answers = []
2066
+
2067
+ # Generate a per-index set of answers for each matrix element
2068
+ for i, j in np.ndindex(self.value.shape):
2069
+ entry_strings = ContentAST.Answer.accepted_strings(
2070
+ self.value[i, j],
2071
+ allow_integer=True,
2072
+ allow_simple_fraction=True,
2073
+ max_denominator=60,
2074
+ allow_mixed=True,
2075
+ include_spaces=False,
2076
+ include_fixed_even_if_integer=True
2077
+ )
2078
+ canvas_answers.extend([
2079
+ {
2080
+ "blank_id": f"{self.key}_{i}_{j}", # Indexed per cell
2081
+ "answer_text": answer_string,
2082
+ "answer_weight": 100 if self.correct else 0,
2083
+ }
2084
+ for answer_string in entry_strings
2085
+ ])
2086
+
2087
+ return canvas_answers
2088
+
2089
+ def render_html(self, **kwargs):
2090
+ """Render as table of answer blanks."""
2091
+ # Create sub-Answer for each cell
2092
+ data = [
2093
+ [
2094
+ ContentAST.Answer.float_value(
2095
+ key=f"{self.key}_{i}_{j}",
2096
+ value=self.value[i, j],
2097
+ blank_length=5
2098
+ )
2099
+ for j in range(self.value.shape[1])
2100
+ ]
2101
+ for i in range(self.value.shape[0])
2102
+ ]
2103
+ table = ContentAST.Table(data)
2104
+
2105
+ if self.label:
2106
+ return ContentAST.Container([
2107
+ ContentAST.Text(f"{self.label} = "),
2108
+ table
2109
+ ]).render_html(**kwargs)
2110
+ return table.render_html(**kwargs)
2111
+
2112
+ def render_latex(self, **kwargs):
2113
+ """Render as LaTeX table of answer blanks."""
2114
+ # Create sub-Answer for each cell
2115
+ data = [
2116
+ [
2117
+ ContentAST.Answer.float_value(
2118
+ key=f"{self.key}_{i}_{j}",
2119
+ value=self.value[i, j],
2120
+ blank_length=5
2121
+ )
2122
+ for j in range(self.value.shape[1])
2123
+ ]
2124
+ for i in range(self.value.shape[0])
2125
+ ]
2126
+ table = ContentAST.Table(data)
2127
+
2128
+ if self.label:
2129
+ return ContentAST.Container([
2130
+ ContentAST.Text(f"{self.label} = "),
2131
+ table
2132
+ ]).render_latex(**kwargs)
2133
+ return table.render_latex(**kwargs)
2134
+
2135
+ def render_markdown(self, **kwargs):
2136
+ """Render as markdown table of answer blanks."""
2137
+ # Create sub-Answer for each cell
2138
+ data = [
2139
+ [
2140
+ ContentAST.Answer.float_value(
2141
+ key=f"{self.key}_{i}_{j}",
2142
+ value=self.value[i, j],
2143
+ blank_length=5
2144
+ )
2145
+ for j in range(self.value.shape[1])
2146
+ ]
2147
+ for i in range(self.value.shape[0])
2148
+ ]
2149
+ table = ContentAST.Table(data)
2150
+
2151
+ if self.label:
2152
+ return ContentAST.Container([
2153
+ ContentAST.Text(f"{self.label} = "),
2154
+ table
2155
+ ]).render_markdown(**kwargs)
2156
+ return table.render_markdown(**kwargs)
2157
+
2158
+ def render_typst(self, **kwargs):
2159
+ """Render as Typst table of answer blanks."""
2160
+ # Create sub-Answer for each cell
2161
+ data = [
2162
+ [
2163
+ ContentAST.Answer.float_value(
2164
+ key=f"{self.key}_{i}_{j}",
2165
+ value=self.value[i, j],
2166
+ blank_length=5
2167
+ )
2168
+ for j in range(self.value.shape[1])
2169
+ ]
2170
+ for i in range(self.value.shape[0])
2171
+ ]
2172
+ table = ContentAST.Table(data)
2173
+
2174
+ if self.label:
2175
+ return ContentAST.Container([
2176
+ ContentAST.Text(f"{self.label} = "),
2177
+ table
2178
+ ]).render_typst(**kwargs)
2179
+ return table.render_typst(**kwargs)
2180
+
1309
2181
  class LineBreak(Text):
1310
2182
  def __init__(self, *args, **kwargs):
1311
2183
  super().__init__("\n\n")
@@ -1745,18 +2617,15 @@ class ContentAST:
1745
2617
  - Better visual grouping of related answers
1746
2618
 
1747
2619
  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)
2620
+ # Multiple related answers - Answer extends Leaf, use factory methods
2621
+ memory_ans = ContentAST.Answer.integer("memory", self.memory_value, label="Memory used", unit="MB")
2622
+ time_ans = ContentAST.Answer.auto_float("time", self.time_value, label="Execution time", unit="ms")
2623
+ answer_block = ContentAST.AnswerBlock([memory_ans, time_ans])
1754
2624
  body.add_element(answer_block)
1755
2625
 
1756
2626
  # Single answer with better spacing
1757
- single_answer = ContentAST.AnswerBlock(
1758
- ContentAST.Answer(answer=self.result, label="Final result")
1759
- )
2627
+ result_ans = ContentAST.Answer.integer("result", self.result_value, label="Final result")
2628
+ single_answer = ContentAST.AnswerBlock(result_ans)
1760
2629
  """
1761
2630
  def __init__(self, answers: ContentAST.Answer|List[ContentAST.Answer]):
1762
2631
  if not isinstance(answers, list):
@@ -1920,9 +2789,9 @@ class ContentAST:
1920
2789
  # Add to main content - only appears in PDF
1921
2790
  body.add_element(latex_only)
1922
2791
  """
1923
-
2792
+
1924
2793
  def render(self, output_format: ContentAST.OutputFormat, **kwargs):
1925
- if output_format != "latex":
2794
+ if output_format not in ("latex", "typst"):
1926
2795
  return ""
1927
2796
  return super().render(output_format=output_format, **kwargs)
1928
2797