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