QuizGenerator 0.5.1__py3-none-any.whl → 0.6.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 (29) hide show
  1. QuizGenerator/contentast.py +1056 -1231
  2. QuizGenerator/generate.py +174 -2
  3. QuizGenerator/misc.py +0 -6
  4. QuizGenerator/mixins.py +7 -8
  5. QuizGenerator/premade_questions/basic.py +3 -3
  6. QuizGenerator/premade_questions/cst334/languages.py +45 -51
  7. QuizGenerator/premade_questions/cst334/math_questions.py +9 -10
  8. QuizGenerator/premade_questions/cst334/memory_questions.py +39 -56
  9. QuizGenerator/premade_questions/cst334/persistence_questions.py +12 -27
  10. QuizGenerator/premade_questions/cst334/process.py +11 -22
  11. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +11 -11
  12. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +7 -7
  13. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +6 -6
  14. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +2 -2
  15. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +15 -19
  16. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +149 -442
  17. QuizGenerator/premade_questions/cst463/models/attention.py +7 -8
  18. QuizGenerator/premade_questions/cst463/models/cnns.py +6 -7
  19. QuizGenerator/premade_questions/cst463/models/rnns.py +6 -6
  20. QuizGenerator/premade_questions/cst463/models/text.py +7 -8
  21. QuizGenerator/premade_questions/cst463/models/weight_counting.py +5 -9
  22. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +22 -22
  23. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +25 -25
  24. QuizGenerator/question.py +13 -14
  25. {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/METADATA +1 -1
  26. {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/RECORD +29 -29
  27. {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/WHEEL +0 -0
  28. {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/entry_points.txt +0 -0
  29. {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/licenses/LICENSE +0 -0
@@ -2,26 +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
20
16
  import os
21
17
  import uuid
18
+ import itertools
19
+ import math
20
+ import decimal
21
+ import fractions
22
+ import numpy as np
22
23
 
23
24
  log = logging.getLogger(__name__)
24
25
 
26
+
25
27
  class ContentAST:
26
28
  """
27
29
  Content Abstract Syntax Tree - The core content system for quiz generation.
@@ -1201,7 +1203,6 @@ class ContentAST:
1201
1203
  super().__init__("[matrix]")
1202
1204
 
1203
1205
  # Convert numpy ndarray to list format if needed
1204
- import numpy as np
1205
1206
  if isinstance(data, np.ndarray):
1206
1207
  if data.ndim == 1:
1207
1208
  # 1D array: convert to column vector [[v1], [v2], [v3]]
@@ -1291,7 +1292,7 @@ class ContentAST:
1291
1292
  def render_html(self, **kwargs):
1292
1293
  matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
1293
1294
  rows = []
1294
- if isinstance(self.data, numpy.ndarray):
1295
+ if isinstance(self.data, np.ndarray):
1295
1296
  data = self.data.tolist()
1296
1297
  else:
1297
1298
  data = self.data
@@ -1467,1359 +1468,1183 @@ class ContentAST:
1467
1468
 
1468
1469
  return "\n".join(result)
1469
1470
 
1470
- class Answer(Leaf):
1471
- """
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.
1476
-
1477
- CRITICAL: Use this for ALL answer inputs in questions.
1478
- Creates appropriate input fields that work across both PDF and Canvas formats.
1479
- In PDF, renders as blank lines for students to fill in.
1480
- In HTML/Canvas, can display the answer for checking.
1481
-
1482
- Example:
1483
- # Basic answer field
1484
- ans = ContentAST.Answer.integer("result", 42, label="Result", unit="MB")
1485
- body.add_element(ans)
1486
- answers.append(ans) # Track for Canvas export
1471
+ class LineBreak(Text):
1472
+ def __init__(self, *args, **kwargs):
1473
+ super().__init__("\n\n")
1474
+
1475
+ ## Containers
1476
+
1477
+ class Paragraph(Container):
1487
1478
  """
1479
+ Text block container with proper spacing and paragraph formatting.
1488
1480
 
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
1481
+ IMPORTANT: Use this for grouping text content, especially in question bodies.
1482
+ Automatically handles spacing between paragraphs and combines multiple
1483
+ lines/elements into a cohesive text block.
1569
1484
 
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
1485
+ When to use:
1486
+ - Question instructions or problem statements
1487
+ - Multi-line text content
1488
+ - Grouping related text elements
1489
+ - Any text that should be visually separated as a paragraph
1577
1490
 
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
1491
+ When NOT to use:
1492
+ - Single words or short phrases (use ContentAST.Text)
1493
+ - Mathematical content (use ContentAST.Equation)
1494
+ - Structured data (use ContentAST.Table)
1582
1495
 
1583
- # Rendering fields
1584
- self.label = label
1585
- self.unit = unit
1586
- self.blank_length = blank_length
1496
+ Example:
1497
+ # Multi-line question text
1498
+ body.add_element(ContentAST.Paragraph([
1499
+ "Consider the following system:",
1500
+ "- Process A requires 4MB memory",
1501
+ "- Process B requires 2MB memory",
1502
+ "How much total memory is needed?"
1503
+ ]))
1587
1504
 
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
1505
+ # Mixed content paragraph
1506
+ para = ContentAST.Paragraph([
1507
+ "The equation ",
1508
+ ContentAST.Equation("x^2 + 1 = 0", inline=True),
1509
+ " has no real solutions."
1510
+ ])
1511
+ """
1512
+
1513
+ def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
1514
+ super().__init__(add_spacing_before=True)
1515
+ for line in lines_or_elements:
1516
+ if isinstance(line, str):
1517
+ self.elements.append(ContentAST.Text(line))
1518
+ else:
1519
+ self.elements.append(line)
1520
+
1521
+ def render(self, output_format, **kwargs):
1522
+ # Add in new lines to break these up visually
1523
+ return "\n\n" + super().render(output_format, **kwargs) + "\n\n"
1524
+
1525
+ def render_html(self, **kwargs):
1526
+ return super().render_html(**kwargs) + "<br>"
1527
+
1528
+ def add_line(self, line: str):
1529
+ self.elements.append(ContentAST.Text(line))
1530
+
1531
+ class Table(Container):
1532
+ """
1533
+ Structured data table with cross-format rendering and proper formatting.
1595
1534
 
1596
- # If this answer is PDF-only, don't send it to Canvas
1597
- if self.pdf_only:
1598
- return []
1535
+ Creates properly formatted tables that work in PDF, Canvas, and Markdown.
1536
+ Automatically handles headers, alignment, and responsive formatting.
1537
+ All data is converted to ContentAST elements for consistent rendering.
1599
1538
 
1600
- canvas_answers = []
1539
+ When to use:
1540
+ - Structured data presentation (comparison tables, data sets)
1541
+ - Answer choices in tabular format
1542
+ - Organized information display
1543
+ - Memory layout diagrams, process tables, etc.
1601
1544
 
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
- ]
1545
+ Features:
1546
+ - Automatic alignment control (left, right, center)
1547
+ - Optional headers with proper formatting
1548
+ - Canvas-compatible HTML output
1549
+ - LaTeX booktabs for professional PDF tables
1615
1550
 
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
- }
1551
+ Example:
1552
+ # Basic data table
1553
+ data = [
1554
+ ["Process A", "4MB", "Running"],
1555
+ ["Process B", "2MB", "Waiting"]
1628
1556
  ]
1557
+ headers = ["Process", "Memory", "Status"]
1558
+ table = ContentAST.Table(data=data, headers=headers, alignments=["left", "right", "center"])
1559
+ body.add_element(table)
1629
1560
 
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
- },
1561
+ # Mixed content table
1562
+ data = [
1563
+ [ContentAST.Text("x"), ContentAST.Equation("x^2", inline=True)],
1564
+ [ContentAST.Text("y"), ContentAST.Equation("y^2", inline=True)]
1657
1565
  ]
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
- ]
1566
+ """
1567
+
1568
+ def __init__(self, data, headers=None, alignments=None, padding=False, transpose=False, hide_rules=False):
1569
+ # todo: fix alignments
1570
+ # todo: implement transpose
1571
+ super().__init__()
1572
+
1573
+ # Normalize data to ContentAST elements
1574
+ self.data = []
1575
+ for row in data:
1576
+ normalized_row = []
1577
+ for cell in row:
1578
+ if isinstance(cell, ContentAST.Element):
1579
+ normalized_row.append(cell)
1580
+ else:
1581
+ normalized_row.append(ContentAST.Text(str(cell)))
1582
+ self.data.append(normalized_row)
1583
+
1584
+ # Normalize headers to ContentAST elements
1585
+ if headers:
1586
+ self.headers = []
1587
+ for header in headers:
1588
+ if isinstance(header, ContentAST.Element):
1589
+ self.headers.append(header)
1590
+ else:
1591
+ self.headers.append(ContentAST.Text(str(header)))
1592
+ else:
1593
+ self.headers = None
1594
+
1595
+ self.alignments = alignments
1596
+ self.padding = padding,
1597
+ self.hide_rules = hide_rules
1598
+
1599
+ def render_markdown(self, **kwargs):
1600
+ # Basic markdown table implementation
1601
+ result = []
1602
+
1603
+ if self.headers:
1604
+ result.append("| " + " | ".join(str(h) for h in self.headers) + " |")
1605
+
1606
+ if self.alignments:
1607
+ align_row = []
1608
+ for align in self.alignments:
1609
+ if align == "left":
1610
+ align_row.append(":---")
1611
+ elif align == "right":
1612
+ align_row.append("---:")
1613
+ else: # center
1614
+ align_row.append(":---:")
1615
+ result.append("| " + " | ".join(align_row) + " |")
1674
1616
  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
1617
+ result.append("| " + " | ".join(["---"] * len(self.headers)) + " |")
1618
+
1619
+ for row in self.data:
1620
+ result.append("| " + " | ".join(str(cell) for cell in row) + " |")
1621
+
1622
+ return "\n".join(result)
1623
+
1624
+ def render_html(self, **kwargs):
1625
+ # HTML table implementation
1626
+ result = ["<table border=\"1\" style=\"border-collapse: collapse; width: 100%;\">"]
1627
+
1628
+ result.append(" <tbody>")
1629
+
1630
+ # Render headers as bold first row instead of <th> tags for Canvas compatibility
1631
+ if self.headers:
1632
+ result.append(" <tr>")
1633
+ for i, header in enumerate(self.headers):
1634
+ align_attr = ""
1635
+ if self.alignments and i < len(self.alignments):
1636
+ align_attr = f' align="{self.alignments[i]}"'
1637
+ # Render header as bold content in regular <td> tag
1638
+ rendered_header = header.render(output_format="html", **kwargs)
1639
+ result.append(
1640
+ f" <td style=\"padding: {'5px' if self.padding else '0x'}; font-weight: bold; {align_attr};\"><b>{rendered_header}</b></td>"
1684
1641
  )
1642
+ result.append(" </tr>")
1643
+
1644
+ # Render data rows
1645
+ for row in self.data:
1646
+ result.append(" <tr>")
1647
+ for i, cell in enumerate(row):
1648
+ if isinstance(cell, ContentAST.Element):
1649
+ cell = cell.render(output_format="html", **kwargs)
1650
+ align_attr = ""
1651
+ if self.alignments and i < len(self.alignments):
1652
+ align_attr = f' align="{self.alignments[i]}"'
1653
+ result.append(f" <td style=\"padding: {'5px' if self.padding else '0x'} ; {align_attr};\">{cell}</td>")
1654
+ result.append(" </tr>")
1655
+ result.append(" </tbody>")
1656
+ result.append("</table>")
1657
+
1658
+ return "\n".join(result)
1659
+
1660
+ def render_latex(self, **kwargs):
1661
+ # LaTeX table implementation
1662
+ if self.alignments:
1663
+ col_spec = "".join(
1664
+ "l" if a == "left" else "r" if a == "right" else "c"
1665
+ for a in self.alignments
1666
+ )
1667
+ else:
1668
+ col_spec = '|'.join(["l"] * (len(self.headers) if self.headers else len(self.data[0])))
1669
+
1670
+ result = [f"\\begin{{tabular}}{{{col_spec}}}"]
1671
+ if not self.hide_rules: result.append("\\toprule")
1672
+
1673
+ if self.headers:
1674
+ # Now all headers are ContentAST elements, so render them consistently
1675
+ rendered_headers = [header.render(output_format="latex", **kwargs) for header in self.headers]
1676
+ result.append(" & ".join(rendered_headers) + " \\\\")
1677
+ if not self.hide_rules: result.append("\\midrule")
1678
+
1679
+ for row in self.data:
1680
+ # All data cells are now ContentAST elements, so render them consistently
1681
+ rendered_row = [cell.render(output_format="latex", **kwargs) for cell in row]
1682
+ result.append(" & ".join(rendered_row) + " \\\\")
1683
+
1684
+ if len(self.data) > 1 and not self.hide_rules:
1685
+ result.append("\\bottomrule")
1686
+ result.append("\\end{tabular}")
1687
+
1688
+ return "\n\n" + "\n".join(result)
1689
+
1690
+ def render_typst(self, **kwargs):
1691
+ """
1692
+ Render table in Typst format using native table() function.
1685
1693
 
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
+ Typst syntax:
1695
+ #table(
1696
+ columns: N,
1697
+ align: (left, center, right),
1698
+ [Header1], [Header2],
1699
+ [Cell1], [Cell2]
1700
+ )
1701
+ """
1702
+ # Determine number of columns
1703
+ num_cols = len(self.headers) if self.headers else len(self.data[0])
1704
+
1705
+ # Build alignment specification
1706
+ if self.alignments:
1707
+ # Map alignment strings to Typst alignment
1708
+ align_map = {"left": "left", "right": "right", "center": "center"}
1709
+ aligns = [align_map.get(a, "left") for a in self.alignments]
1710
+ align_spec = f"align: ({', '.join(aligns)})"
1711
+ else:
1712
+ align_spec = "align: left"
1713
+
1714
+ # Start table
1715
+ result = [f"table("]
1716
+ result.append(f" columns: {num_cols},")
1717
+ result.append(f" {align_spec},")
1718
+
1719
+ # Add stroke if not hiding rules
1720
+ if not self.hide_rules:
1721
+ result.append(f" stroke: 0.5pt,")
1722
+ else:
1723
+ result.append(f" stroke: none,")
1724
+
1725
+ # Collect all rows (headers + data) and calculate column widths for alignment
1726
+ all_rows = []
1727
+
1728
+ # Render headers
1729
+ if self.headers:
1730
+ header_cells = []
1731
+ for header in self.headers:
1732
+ rendered = header.render(output_format="typst", **kwargs).strip()
1733
+ header_cells.append(f"[*{rendered}*]")
1734
+ all_rows.append(header_cells)
1735
+
1736
+ # Render data rows
1737
+ for row in self.data:
1738
+ row_cells = []
1739
+ for cell in row:
1740
+ rendered = cell.render(output_format="typst", **kwargs).strip()
1741
+ row_cells.append(f"[{rendered}]")
1742
+ all_rows.append(row_cells)
1743
+
1744
+ # Calculate max width for each column
1745
+ col_widths = [0] * num_cols
1746
+ for row in all_rows:
1747
+ for i, cell in enumerate(row):
1748
+ col_widths[i] = max(col_widths[i], len(cell))
1749
+
1750
+ # Format rows with padding
1751
+ for row in all_rows:
1752
+ padded_cells = []
1753
+ for i, cell in enumerate(row):
1754
+ padded_cells.append(cell.ljust(col_widths[i]))
1755
+ result.append(f" {', '.join(padded_cells)},")
1756
+
1757
+ result.append(")")
1758
+
1759
+ return "\n#box(" + "\n".join(result) + "\n)"
1760
+
1761
+ class TableGroup(Container):
1762
+ """
1763
+ Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
1694
1764
 
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
- ]
1765
+ Use this when you need to show multiple related tables together, such as
1766
+ multiple page tables in hierarchical paging questions. In LaTeX, tables
1767
+ are displayed side-by-side using minipages. In HTML/Canvas, they're stacked
1768
+ vertically for better mobile compatibility.
1701
1769
 
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
- })
1770
+ When to use:
1771
+ - Multiple related tables that should be visually grouped
1772
+ - Page tables in hierarchical paging
1773
+ - Comparison of multiple data structures
1710
1774
 
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
1775
+ Features:
1776
+ - Automatic side-by-side layout in PDF (using minipages)
1777
+ - Vertical stacking in HTML for better readability
1778
+ - Automatic width calculation based on number of tables
1779
+ - Optional labels for each table
1719
1780
 
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
- ]
1781
+ Example:
1782
+ # Create table group with labels
1783
+ table_group = ContentAST.TableGroup()
1729
1784
 
1730
- else:
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
- }]
1785
+ table_group.add_table(
1786
+ label="Page Table #0",
1787
+ table=ContentAST.Table(headers=["PTI", "PTE"], data=pt0_data)
1788
+ )
1747
1789
 
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
- })
1790
+ table_group.add_table(
1791
+ label="Page Table #1",
1792
+ table=ContentAST.Table(headers=["PTI", "PTE"], data=pt1_data)
1793
+ )
1756
1794
 
1757
- return canvas_answers
1795
+ body.add_element(table_group)
1796
+ """
1797
+ def __init__(self):
1798
+ super().__init__()
1799
+ self.tables = [] # List of (label, table) tuples
1758
1800
 
1759
- def get_display_string(self) -> str:
1760
- """Get the formatted display string for this answer (for grading/answer keys)."""
1761
- import math
1801
+ def add_table(self, table: ContentAST.Table, label: str = None):
1802
+ """
1803
+ Add a table to the group with an optional label.
1762
1804
 
1763
- def fix_negative_zero(x):
1764
- """Fix -0.0 display issue."""
1765
- return 0.0 if x == 0 else x
1805
+ Args:
1806
+ table: ContentAST.Table to add
1807
+ label: Optional label to display above the table
1808
+ """
1809
+ self.tables.append((label, table))
1766
1810
 
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}"
1811
+ def render_html(self, **kwargs):
1812
+ # Stack tables vertically in HTML
1813
+ result = []
1814
+ for label, table in self.tables:
1815
+ if label:
1816
+ result.append(f"<p><b>{label}</b></p>")
1817
+ result.append(table.render("html", **kwargs))
1818
+ return "\n".join(result)
1770
1819
 
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}"
1820
+ def render_latex(self, **kwargs):
1821
+ if not self.tables:
1822
+ return ""
1773
1823
 
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}"
1824
+ # Calculate width based on number of tables
1825
+ num_tables = len(self.tables)
1826
+ if num_tables == 1:
1827
+ width = 0.9
1828
+ elif num_tables == 2:
1829
+ width = 0.45
1830
+ else: # 3 or more
1831
+ width = 0.30
1777
1832
 
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)}"
1833
+ result = ["\n\n"] # Add spacing before table group
1781
1834
 
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)}"
1835
+ for i, (label, table) in enumerate(self.tables):
1836
+ result.append(f"\\begin{{minipage}}{{{width}\\textwidth}}")
1788
1837
 
1789
- elif self.variable_kind == ContentAST.Answer.VariableKind.INT:
1790
- return str(int(self.value))
1838
+ if label:
1839
+ # Escape # characters in labels for LaTeX
1840
+ escaped_label = label.replace("#", r"\#")
1841
+ result.append(f"\\textbf{{{escaped_label}}}")
1842
+ result.append("\\vspace{0.1cm}")
1791
1843
 
1792
- elif self.variable_kind == ContentAST.Answer.VariableKind.LIST:
1793
- return ", ".join(str(v) for v in self.value)
1844
+ # Render the table
1845
+ table_latex = table.render("latex", **kwargs)
1846
+ result.append(table_latex)
1794
1847
 
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)
1848
+ result.append("\\end{minipage}")
1799
1849
 
1800
- else:
1801
- return str(self.display if hasattr(self, 'display') else self.value)
1850
+ # Add horizontal spacing between tables (but not after the last one)
1851
+ if i < num_tables - 1:
1852
+ result.append("\\hfill")
1802
1853
 
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
-
1807
- def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
1808
- if can_be_numerical:
1809
- return f"Calculate {self.label}"
1810
- if show_answers:
1811
- answer_display = self.get_display_string()
1812
- label_part = f"{self.label}:" if self.label else ""
1813
- unit_part = f" {self.unit}" if self.unit else ""
1814
- return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
1815
- else:
1816
- return self.render_markdown(**kwargs)
1817
-
1818
- def render_latex(self, **kwargs):
1819
- return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.blank_length}}} {self.unit}".strip()
1854
+ return "\n".join(result)
1820
1855
 
1821
1856
  def render_typst(self, **kwargs):
1822
- """Render answer blank as an underlined space in Typst."""
1823
- blank_width = self.blank_length * 0.75 # Convert character length to cm
1824
- blank = f"#fillline(width: {blank_width}cm)"
1857
+ """
1858
+ Render table group in Typst format using grid layout for side-by-side tables.
1825
1859
 
1826
- label_part = f"{self.label}:" if self.label else ""
1827
- unit_part = f" {self.unit}" if self.unit else ""
1860
+ Uses Typst's grid() function to arrange tables horizontally with automatic
1861
+ column sizing and spacing.
1862
+ """
1863
+ if not self.tables:
1864
+ return ""
1828
1865
 
1829
- return f"{label_part} {blank}{unit_part}".strip()
1866
+ num_tables = len(self.tables)
1830
1867
 
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
- )
1868
+ # Start grid with equal-width columns and some spacing
1869
+ result = ["\n#grid("]
1870
+ result.append(f" columns: {num_tables},")
1871
+ result.append(f" column-gutter: 1em,")
1872
+ result.append(f" row-gutter: 0.5em,")
1842
1873
 
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
- )
1874
+ # Add each table as a grid cell
1875
+ for label, table in self.tables:
1876
+ result.append(" [") # Start grid cell
1852
1877
 
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
- )
1878
+ if label:
1879
+ # Escape # characters in labels (already done by Text.render_typst)
1880
+ result.append(f" *{label}*")
1881
+ result.append(" #v(0.1cm)")
1882
+ result.append("") # Empty line for spacing
1862
1883
 
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
- )
1884
+ # Render the table (indent for readability)
1885
+ table_typst = table.render("typst", **kwargs)
1886
+ # Indent each line of the table
1887
+ indented_table = "\n".join(f" {line}" if line else "" for line in table_typst.split("\n"))
1888
+ result.append(indented_table)
1872
1889
 
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
- )
1890
+ result.append(" ],") # End grid cell
1883
1891
 
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
- )
1892
+ result.append(")")
1893
+ result.append("") # Empty line after grid
1894
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
- )
1895
+ return "\n".join(result)
1904
1896
 
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
- )
1897
+ class AnswerBlock(Table):
1898
+ """
1899
+ Specialized table for organizing multiple answer fields with proper spacing.
1914
1900
 
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
- )
1901
+ Creates a clean layout for multiple answer inputs with extra vertical
1902
+ spacing in PDF output. Inherits from Table but optimized for answers.
1924
1903
 
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
- )
1904
+ When to use:
1905
+ - Questions with multiple answer fields
1906
+ - Organized answer input sections
1907
+ - Better visual grouping of related answers
1935
1908
 
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
- )
1909
+ Example:
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])
1914
+ body.add_element(answer_block)
1946
1915
 
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
- )
1916
+ # Single answer with better spacing
1917
+ result_ans = ContentAST.Answer.integer("result", self.result_value, label="Final result")
1918
+ single_answer = ContentAST.AnswerBlock(result_ans)
1919
+ """
1920
+ def __init__(self, answers: ContentAST.Answer|List[ContentAST.Answer]):
1921
+ if not isinstance(answers, list):
1922
+ answers = [answers]
1956
1923
 
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
1924
+ super().__init__(
1925
+ data=[
1926
+ [answer]
1927
+ for answer in answers
1928
+ ]
1965
1929
  )
1930
+ self.hide_rules = True
1966
1931
 
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()
1932
+ def add_element(self, element):
1933
+ self.data.append(element)
2016
1934
 
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'))
1935
+ def render_latex(self, **kwargs):
1936
+ rendered_content = super().render_latex(**kwargs)
1937
+ content = (
1938
+ r"{"
1939
+ r"\setlength{\extrarowheight}{20pt}"
1940
+ + rendered_content +
1941
+ r"}"
1942
+ )
1943
+ return content
2024
1944
 
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'))
1945
+ ## Specialized Elements
1946
+ class RepeatedProblemPart(Container):
1947
+ """
1948
+ Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
2029
1949
 
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)
1950
+ Creates the specialized alignat* LaTeX format for multipart math problems
1951
+ where each subpart is labeled and aligned properly. Used primarily for
1952
+ vector math questions that need multiple similar calculations.
2040
1953
 
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}")
1954
+ When to use:
1955
+ - Questions with multiple subparts that need (a), (b), (c) labeling
1956
+ - Vector math problems with repeated calculations
1957
+ - Any math problem where subparts should be aligned
2054
1958
 
2055
- return sorted(outs, key=lambda s: (len(s), s))
1959
+ Features:
1960
+ - Automatic subpart labeling with (a), (b), (c), etc.
1961
+ - Proper LaTeX alignat* formatting for PDF
1962
+ - HTML fallback with organized layout
1963
+ - Flexible content support (equations, matrices, etc.)
2056
1964
 
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).
1965
+ Example:
1966
+ # Create subparts for vector dot products
1967
+ subparts = [
1968
+ (ContentAST.Matrix([[1], [2]]), "\\cdot", ContentAST.Matrix([[3], [4]])),
1969
+ (ContentAST.Matrix([[5], [6]]), "\\cdot", ContentAST.Matrix([[7], [8]]))
1970
+ ]
1971
+ repeated_part = ContentAST.RepeatedProblemPart(subparts)
1972
+ body.add_element(repeated_part)
2060
1973
  """
1974
+ def __init__(self, subpart_contents):
1975
+ """
1976
+ Create a repeated problem part with multiple subquestions.
2061
1977
 
2062
- def get_for_canvas(self, single_answer=False) -> List[dict]:
2063
- """Generate Canvas answers for each matrix element."""
2064
- import numpy as np
1978
+ Args:
1979
+ subpart_contents: List of content for each subpart.
1980
+ Each item can be:
1981
+ - A string (rendered as equation)
1982
+ - A ContentAST.Element
1983
+ - A tuple/list of elements to be joined
1984
+ """
1985
+ super().__init__()
1986
+ self.subpart_contents = subpart_contents
2065
1987
 
2066
- canvas_answers = []
1988
+ def render_markdown(self, **kwargs):
1989
+ result = []
1990
+ for i, content in enumerate(self.subpart_contents):
1991
+ letter = chr(ord('a') + i) # Convert to (a), (b), (c), etc.
1992
+ if isinstance(content, str):
1993
+ result.append(f"({letter}) {content}")
1994
+ elif isinstance(content, (list, tuple)):
1995
+ content_str = " ".join(str(item) for item in content)
1996
+ result.append(f"({letter}) {content_str}")
1997
+ else:
1998
+ result.append(f"({letter}) {str(content)}")
1999
+ return "\n\n".join(result)
2067
2000
 
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
- ])
2001
+ def render_html(self, **kwargs):
2002
+ result = []
2003
+ for i, content in enumerate(self.subpart_contents):
2004
+ letter = chr(ord('a') + i)
2005
+ if isinstance(content, str):
2006
+ result.append(f"<p>({letter}) {content}</p>")
2007
+ elif isinstance(content, (list, tuple)):
2008
+ rendered_items = []
2009
+ for item in content:
2010
+ if hasattr(item, 'render'):
2011
+ rendered_items.append(item.render('html', **kwargs))
2012
+ else:
2013
+ rendered_items.append(str(item))
2014
+ content_str = " ".join(rendered_items)
2015
+ result.append(f"<p>({letter}) {content_str}</p>")
2016
+ else:
2017
+ if hasattr(content, 'render'):
2018
+ content_str = content.render('html', **kwargs)
2019
+ else:
2020
+ content_str = str(content)
2021
+ result.append(f"<p>({letter}) {content_str}</p>")
2022
+ return "\n".join(result)
2087
2023
 
2088
- return canvas_answers
2024
+ def render_latex(self, **kwargs):
2025
+ if not self.subpart_contents:
2026
+ return ""
2089
2027
 
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)
2028
+ # Start alignat environment - use 2 columns for alignment
2029
+ result = [r"\begin{alignat*}{2}"]
2105
2030
 
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)
2031
+ for i, content in enumerate(self.subpart_contents):
2032
+ letter = chr(ord('a') + i)
2033
+ spacing = r"\\[6pt]" if i < len(self.subpart_contents) - 1 else r" \\"
2112
2034
 
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)
2035
+ if isinstance(content, str):
2036
+ # Treat as raw LaTeX equation content
2037
+ result.append(f"({letter})\\;& {content} &=&\\; {spacing}")
2038
+ elif isinstance(content, (list, tuple)):
2039
+ # Join multiple elements (e.g., matrix, operator, matrix)
2040
+ rendered_items = []
2041
+ for item in content:
2042
+ if hasattr(item, 'render'):
2043
+ rendered_items.append(item.render('latex', **kwargs))
2044
+ elif isinstance(item, str):
2045
+ rendered_items.append(item)
2046
+ else:
2047
+ rendered_items.append(str(item))
2048
+ content_str = " ".join(rendered_items)
2049
+ result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
2050
+ else:
2051
+ # Single element (ContentAST element or string)
2052
+ if hasattr(content, 'render'):
2053
+ content_str = content.render('latex', **kwargs)
2054
+ else:
2055
+ content_str = str(content)
2056
+ result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
2128
2057
 
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)
2058
+ result.append(r"\end{alignat*}")
2059
+ return "\n".join(result)
2135
2060
 
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)
2061
+ class OnlyLatex(Container):
2062
+ """
2063
+ Container element that only renders content in LaTeX/PDF output format.
2151
2064
 
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)
2065
+ Use this when you need LaTeX-specific content that should not appear
2066
+ in HTML/Canvas or Markdown outputs. Content is completely hidden
2067
+ from non-LaTeX formats.
2158
2068
 
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)
2069
+ When to use:
2070
+ - LaTeX-specific formatting that has no HTML equivalent
2071
+ - PDF-only instructions or content
2072
+ - Complex LaTeX commands that break HTML rendering
2174
2073
 
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)
2074
+ Example:
2075
+ # LaTeX-only spacing or formatting
2076
+ latex_only = ContentAST.OnlyLatex()
2077
+ latex_only.add_element(ContentAST.Text("\\newpage"))
2181
2078
 
2182
- class LineBreak(Text):
2183
- def __init__(self, *args, **kwargs):
2184
- super().__init__("\n\n")
2185
-
2186
- ## Containers
2079
+ # Add to main content - only appears in PDF
2080
+ body.add_element(latex_only)
2081
+ """
2082
+
2083
+ def render(self, output_format: ContentAST.OutputFormat, **kwargs):
2084
+ if output_format not in ("latex", "typst"):
2085
+ return ""
2086
+ return super().render(output_format=output_format, **kwargs)
2187
2087
 
2188
- class Paragraph(Container):
2088
+ class OnlyHtml(Container):
2189
2089
  """
2190
- Text block container with proper spacing and paragraph formatting.
2090
+ Container element that only renders content in HTML/Canvas output format.
2191
2091
 
2192
- IMPORTANT: Use this for grouping text content, especially in question bodies.
2193
- Automatically handles spacing between paragraphs and combines multiple
2194
- lines/elements into a cohesive text block.
2092
+ Use this when you need HTML-specific content that should not appear
2093
+ in LaTeX/PDF or Markdown outputs. Content is completely hidden
2094
+ from non-HTML formats.
2195
2095
 
2196
2096
  When to use:
2197
- - Question instructions or problem statements
2198
- - Multi-line text content
2199
- - Grouping related text elements
2200
- - Any text that should be visually separated as a paragraph
2201
-
2202
- When NOT to use:
2203
- - Single words or short phrases (use ContentAST.Text)
2204
- - Mathematical content (use ContentAST.Equation)
2205
- - Structured data (use ContentAST.Table)
2097
+ - Canvas-specific instructions or formatting
2098
+ - HTML-only interactive elements
2099
+ - Content that doesn't translate well to PDF
2206
2100
 
2207
2101
  Example:
2208
- # Multi-line question text
2209
- body.add_element(ContentAST.Paragraph([
2210
- "Consider the following system:",
2211
- "- Process A requires 4MB memory",
2212
- "- Process B requires 2MB memory",
2213
- "How much total memory is needed?"
2214
- ]))
2102
+ # HTML-only instructions
2103
+ html_only = ContentAST.OnlyHtml()
2104
+ html_only.add_element(ContentAST.Text("Click submit when done"))
2215
2105
 
2216
- # Mixed content paragraph
2217
- para = ContentAST.Paragraph([
2218
- "The equation ",
2219
- ContentAST.Equation("x^2 + 1 = 0", inline=True),
2220
- " has no real solutions."
2221
- ])
2106
+ # Add to main content - only appears in Canvas
2107
+ body.add_element(html_only)
2222
2108
  """
2223
2109
 
2224
- def __init__(self, lines_or_elements: List[str | ContentAST.Element] = None):
2225
- super().__init__(add_spacing_before=True)
2226
- for line in lines_or_elements:
2227
- if isinstance(line, str):
2228
- self.elements.append(ContentAST.Text(line))
2229
- else:
2230
- self.elements.append(line)
2231
-
2232
2110
  def render(self, output_format, **kwargs):
2233
- # Add in new lines to break these up visually
2234
- return "\n\n" + super().render(output_format, **kwargs) + "\n\n"
2235
-
2236
- def render_html(self, **kwargs):
2237
- return super().render_html(**kwargs) + "<br>"
2238
-
2239
- def add_line(self, line: str):
2240
- self.elements.append(ContentAST.Text(line))
2111
+ if output_format != "html":
2112
+ return ""
2113
+ return super().render(output_format, **kwargs)
2241
2114
 
2242
- class Table(Container):
2115
+ class Answer(Leaf):
2243
2116
  """
2244
- Structured data table with cross-format rendering and proper formatting.
2245
-
2246
- Creates properly formatted tables that work in PDF, Canvas, and Markdown.
2247
- Automatically handles headers, alignment, and responsive formatting.
2248
- All data is converted to ContentAST elements for consistent rendering.
2117
+ Unified answer class combining data storage, Canvas export, and rendering.
2249
2118
 
2250
- When to use:
2251
- - Structured data presentation (comparison tables, data sets)
2252
- - Answer choices in tabular format
2253
- - Organized information display
2254
- - Memory layout diagrams, process tables, etc.
2119
+ Extends ContentAST.Leaf to integrate seamlessly with the ContentAST tree while
2120
+ maintaining all Canvas export functionality.
2255
2121
 
2256
- Features:
2257
- - Automatic alignment control (left, right, center)
2258
- - Optional headers with proper formatting
2259
- - Canvas-compatible HTML output
2260
- - LaTeX booktabs for professional PDF tables
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.
2261
2126
 
2262
2127
  Example:
2263
- # Basic data table
2264
- data = [
2265
- ["Process A", "4MB", "Running"],
2266
- ["Process B", "2MB", "Waiting"]
2267
- ]
2268
- headers = ["Process", "Memory", "Status"]
2269
- table = ContentAST.Table(data=data, headers=headers, alignments=["left", "right", "center"])
2270
- body.add_element(table)
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
+ """
2271
2133
 
2272
- # Mixed content table
2273
- data = [
2274
- [ContentAST.Text("x"), ContentAST.Equation("x^2", inline=True)],
2275
- [ContentAST.Text("y"), ContentAST.Equation("y^2", inline=True)]
2276
- ]
2277
- """
2278
-
2279
- def __init__(self, data, headers=None, alignments=None, padding=False, transpose=False, hide_rules=False):
2280
- # todo: fix alignments
2281
- # todo: implement transpose
2282
- super().__init__()
2283
-
2284
- # Normalize data to ContentAST elements
2285
- self.data = []
2286
- for row in data:
2287
- normalized_row = []
2288
- for cell in row:
2289
- if isinstance(cell, ContentAST.Element):
2290
- normalized_row.append(cell)
2291
- else:
2292
- normalized_row.append(ContentAST.Text(str(cell)))
2293
- self.data.append(normalized_row)
2294
-
2295
- # Normalize headers to ContentAST elements
2296
- if headers:
2297
- self.headers = []
2298
- for header in headers:
2299
- if isinstance(header, ContentAST.Element):
2300
- self.headers.append(header)
2301
- else:
2302
- self.headers.append(ContentAST.Text(str(header)))
2303
- else:
2304
- self.headers = None
2305
-
2306
- self.alignments = alignments
2307
- self.padding = padding,
2308
- self.hide_rules = hide_rules
2309
-
2310
- def render_markdown(self, **kwargs):
2311
- # Basic markdown table implementation
2312
- result = []
2313
-
2314
- if self.headers:
2315
- result.append("| " + " | ".join(str(h) for h in self.headers) + " |")
2316
-
2317
- if self.alignments:
2318
- align_row = []
2319
- for align in self.alignments:
2320
- if align == "left":
2321
- align_row.append(":---")
2322
- elif align == "right":
2323
- align_row.append("---:")
2324
- else: # center
2325
- align_row.append(":---:")
2326
- result.append("| " + " | ".join(align_row) + " |")
2327
- else:
2328
- result.append("| " + " | ".join(["---"] * len(self.headers)) + " |")
2329
-
2330
- for row in self.data:
2331
- result.append("| " + " | ".join(str(cell) for cell in row) + " |")
2332
-
2333
- return "\n".join(result)
2334
-
2335
- def render_html(self, **kwargs):
2336
- # HTML table implementation
2337
- result = ["<table border=\"1\" style=\"border-collapse: collapse; width: 100%;\">"]
2338
-
2339
- result.append(" <tbody>")
2340
-
2341
- # Render headers as bold first row instead of <th> tags for Canvas compatibility
2342
- if self.headers:
2343
- result.append(" <tr>")
2344
- for i, header in enumerate(self.headers):
2345
- align_attr = ""
2346
- if self.alignments and i < len(self.alignments):
2347
- align_attr = f' align="{self.alignments[i]}"'
2348
- # Render header as bold content in regular <td> tag
2349
- rendered_header = header.render(output_format="html", **kwargs)
2350
- result.append(
2351
- f" <td style=\"padding: {'5px' if self.padding else '0x'}; font-weight: bold; {align_attr};\"><b>{rendered_header}</b></td>"
2352
- )
2353
- result.append(" </tr>")
2354
-
2355
- # Render data rows
2356
- for row in self.data:
2357
- result.append(" <tr>")
2358
- for i, cell in enumerate(row):
2359
- if isinstance(cell, ContentAST.Element):
2360
- cell = cell.render(output_format="html", **kwargs)
2361
- align_attr = ""
2362
- if self.alignments and i < len(self.alignments):
2363
- align_attr = f' align="{self.alignments[i]}"'
2364
- result.append(f" <td style=\"padding: {'5px' if self.padding else '0x'} ; {align_attr};\">{cell}</td>")
2365
- result.append(" </tr>")
2366
- result.append(" </tbody>")
2367
- result.append("</table>")
2368
-
2369
- return "\n".join(result)
2370
-
2371
- def render_latex(self, **kwargs):
2372
- # LaTeX table implementation
2373
- if self.alignments:
2374
- col_spec = "".join(
2375
- "l" if a == "left" else "r" if a == "right" else "c"
2376
- for a in self.alignments
2377
- )
2378
- else:
2379
- col_spec = '|'.join(["l"] * (len(self.headers) if self.headers else len(self.data[0])))
2380
-
2381
- result = [f"\\begin{{tabular}}{{{col_spec}}}"]
2382
- if not self.hide_rules: result.append("\\toprule")
2383
-
2384
- if self.headers:
2385
- # Now all headers are ContentAST elements, so render them consistently
2386
- rendered_headers = [header.render(output_format="latex", **kwargs) for header in self.headers]
2387
- result.append(" & ".join(rendered_headers) + " \\\\")
2388
- if not self.hide_rules: result.append("\\midrule")
2389
-
2390
- for row in self.data:
2391
- # All data cells are now ContentAST elements, so render them consistently
2392
- rendered_row = [cell.render(output_format="latex", **kwargs) for cell in row]
2393
- result.append(" & ".join(rendered_row) + " \\\\")
2394
-
2395
- if len(self.data) > 1 and not self.hide_rules:
2396
- result.append("\\bottomrule")
2397
- result.append("\\end{tabular}")
2398
-
2399
- return "\n\n" + "\n".join(result)
2400
-
2401
- def render_typst(self, **kwargs):
2402
- """
2403
- Render table in Typst format using native table() function.
2404
-
2405
- Typst syntax:
2406
- #table(
2407
- columns: N,
2408
- align: (left, center, right),
2409
- [Header1], [Header2],
2410
- [Cell1], [Cell2]
2411
- )
2412
- """
2413
- # Determine number of columns
2414
- num_cols = len(self.headers) if self.headers else len(self.data[0])
2415
-
2416
- # Build alignment specification
2417
- if self.alignments:
2418
- # Map alignment strings to Typst alignment
2419
- align_map = {"left": "left", "right": "right", "center": "center"}
2420
- aligns = [align_map.get(a, "left") for a in self.alignments]
2421
- align_spec = f"align: ({', '.join(aligns)})"
2422
- else:
2423
- align_spec = "align: left"
2424
-
2425
- # Start table
2426
- result = [f"table("]
2427
- result.append(f" columns: {num_cols},")
2428
- result.append(f" {align_spec},")
2429
-
2430
- # Add stroke if not hiding rules
2431
- if not self.hide_rules:
2432
- result.append(f" stroke: 0.5pt,")
2433
- else:
2434
- result.append(f" stroke: none,")
2435
-
2436
- # Collect all rows (headers + data) and calculate column widths for alignment
2437
- all_rows = []
2438
-
2439
- # Render headers
2440
- if self.headers:
2441
- header_cells = []
2442
- for header in self.headers:
2443
- rendered = header.render(output_format="typst", **kwargs).strip()
2444
- header_cells.append(f"[*{rendered}*]")
2445
- all_rows.append(header_cells)
2446
-
2447
- # Render data rows
2448
- for row in self.data:
2449
- row_cells = []
2450
- for cell in row:
2451
- rendered = cell.render(output_format="typst", **kwargs).strip()
2452
- row_cells.append(f"[{rendered}]")
2453
- all_rows.append(row_cells)
2454
-
2455
- # Calculate max width for each column
2456
- col_widths = [0] * num_cols
2457
- for row in all_rows:
2458
- for i, cell in enumerate(row):
2459
- col_widths[i] = max(col_widths[i], len(cell))
2460
-
2461
- # Format rows with padding
2462
- for row in all_rows:
2463
- padded_cells = []
2464
- for i, cell in enumerate(row):
2465
- padded_cells.append(cell.ljust(col_widths[i]))
2466
- result.append(f" {', '.join(padded_cells)},")
2467
-
2468
- result.append(")")
2469
-
2470
- return "\n#box(" + "\n".join(result) + "\n)"
2471
-
2472
- class TableGroup(Container):
2473
- """
2474
- Container for displaying multiple tables side-by-side in LaTeX, stacked in HTML.
2475
-
2476
- Use this when you need to show multiple related tables together, such as
2477
- multiple page tables in hierarchical paging questions. In LaTeX, tables
2478
- are displayed side-by-side using minipages. In HTML/Canvas, they're stacked
2479
- vertically for better mobile compatibility.
2480
-
2481
- When to use:
2482
- - Multiple related tables that should be visually grouped
2483
- - Page tables in hierarchical paging
2484
- - Comparison of multiple data structures
2485
-
2486
- Features:
2487
- - Automatic side-by-side layout in PDF (using minipages)
2488
- - Vertical stacking in HTML for better readability
2489
- - Automatic width calculation based on number of tables
2490
- - Optional labels for each table
2491
-
2492
- Example:
2493
- # Create table group with labels
2494
- table_group = ContentAST.TableGroup()
2495
-
2496
- table_group.add_table(
2497
- label="Page Table #0",
2498
- table=ContentAST.Table(headers=["PTI", "PTE"], data=pt0_data)
2499
- )
2500
-
2501
- table_group.add_table(
2502
- label="Page Table #1",
2503
- table=ContentAST.Table(headers=["PTI", "PTE"], data=pt1_data)
2504
- )
2505
-
2506
- body.add_element(table_group)
2507
- """
2508
- def __init__(self):
2509
- super().__init__()
2510
- self.tables = [] # List of (label, table) tuples
2511
-
2512
- def add_table(self, table: ContentAST.Table, label: str = None):
2513
- """
2514
- Add a table to the group with an optional label.
2515
-
2516
- Args:
2517
- table: ContentAST.Table to add
2518
- label: Optional label to display above the table
2519
- """
2520
- self.tables.append((label, table))
2521
-
2522
- def render_html(self, **kwargs):
2523
- # Stack tables vertically in HTML
2524
- result = []
2525
- for label, table in self.tables:
2526
- if label:
2527
- result.append(f"<p><b>{label}</b></p>")
2528
- result.append(table.render("html", **kwargs))
2529
- return "\n".join(result)
2530
-
2531
- def render_latex(self, **kwargs):
2532
- if not self.tables:
2533
- return ""
2534
-
2535
- # Calculate width based on number of tables
2536
- num_tables = len(self.tables)
2537
- if num_tables == 1:
2538
- width = 0.9
2539
- elif num_tables == 2:
2540
- width = 0.45
2541
- else: # 3 or more
2542
- width = 0.30
2543
-
2544
- result = ["\n\n"] # Add spacing before table group
2545
-
2546
- for i, (label, table) in enumerate(self.tables):
2547
- result.append(f"\\begin{{minipage}}{{{width}\\textwidth}}")
2548
-
2549
- if label:
2550
- # Escape # characters in labels for LaTeX
2551
- escaped_label = label.replace("#", r"\#")
2552
- result.append(f"\\textbf{{{escaped_label}}}")
2553
- result.append("\\vspace{0.1cm}")
2554
-
2555
- # Render the table
2556
- table_latex = table.render("latex", **kwargs)
2557
- result.append(table_latex)
2558
-
2559
- result.append("\\end{minipage}")
2560
-
2561
- # Add horizontal spacing between tables (but not after the last one)
2562
- if i < num_tables - 1:
2563
- result.append("\\hfill")
2564
-
2565
- return "\n".join(result)
2566
-
2567
- def render_typst(self, **kwargs):
2568
- """
2569
- Render table group in Typst format using grid layout for side-by-side tables.
2570
-
2571
- Uses Typst's grid() function to arrange tables horizontally with automatic
2572
- column sizing and spacing.
2573
- """
2574
- if not self.tables:
2575
- return ""
2576
-
2577
- num_tables = len(self.tables)
2578
-
2579
- # Start grid with equal-width columns and some spacing
2580
- result = ["\n#grid("]
2581
- result.append(f" columns: {num_tables},")
2582
- result.append(f" column-gutter: 1em,")
2583
- result.append(f" row-gutter: 0.5em,")
2584
-
2585
- # Add each table as a grid cell
2586
- for label, table in self.tables:
2587
- result.append(" [") # Start grid cell
2588
-
2589
- if label:
2590
- # Escape # characters in labels (already done by Text.render_typst)
2591
- result.append(f" *{label}*")
2592
- result.append(" #v(0.1cm)")
2593
- result.append("") # Empty line for spacing
2594
-
2595
- # Render the table (indent for readability)
2596
- table_typst = table.render("typst", **kwargs)
2597
- # Indent each line of the table
2598
- indented_table = "\n".join(f" {line}" if line else "" for line in table_typst.split("\n"))
2599
- result.append(indented_table)
2600
-
2601
- result.append(" ],") # End grid cell
2134
+ DEFAULT_ROUNDING_DIGITS = 4
2602
2135
 
2603
- result.append(")")
2604
- result.append("") # Empty line after grid
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"
2605
2142
 
2606
- return "\n".join(result)
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()
2607
2154
 
2608
- class AnswerBlock(Table):
2609
- """
2610
- Specialized table for organizing multiple answer fields with proper spacing.
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 "")
2611
2175
 
2612
- Creates a clean layout for multiple answer inputs with extra vertical
2613
- spacing in PDF output. Inherits from Table but optimized for answers.
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
2614
2181
 
2615
- When to use:
2616
- - Questions with multiple answer fields
2617
- - Organized answer input sections
2618
- - Better visual grouping of related answers
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
2619
2189
 
2620
- Example:
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])
2625
- body.add_element(answer_block)
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
2626
2194
 
2627
- # Single answer with better spacing
2628
- result_ans = ContentAST.Answer.integer("result", self.result_value, label="Final result")
2629
- single_answer = ContentAST.AnswerBlock(result_ans)
2630
- """
2631
- def __init__(self, answers: ContentAST.Answer|List[ContentAST.Answer]):
2632
- if not isinstance(answers, list):
2633
- answers = [answers]
2195
+ # Rendering fields
2196
+ self.label = label
2197
+ self.unit = unit
2198
+ self.blank_length = blank_length
2634
2199
 
2635
- super().__init__(
2636
- data=[
2637
- [answer]
2638
- for answer in answers
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
2639
2217
  ]
2640
- )
2641
- self.hide_rules = True
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
+ }]
2642
2224
 
2643
- def add_element(self, element):
2644
- self.data.append(element)
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
+ })
2645
2233
 
2646
- def render_latex(self, **kwargs):
2647
- rendered_content = super().render_latex(**kwargs)
2648
- content = (
2649
- r"{"
2650
- r"\setlength{\extrarowheight}{20pt}"
2651
- + rendered_content +
2652
- r"}"
2653
- )
2654
- return content
2234
+ return canvas_answers
2655
2235
 
2656
- ## Specialized Elements
2657
- class RepeatedProblemPart(Container):
2658
- """
2659
- Multi-part problem renderer for questions with labeled subparts (a), (b), (c), etc.
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)
2660
2239
 
2661
- Creates the specialized alignat* LaTeX format for multipart math problems
2662
- where each subpart is labeled and aligned properly. Used primarily for
2663
- vector math questions that need multiple similar calculations.
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()
2664
2243
 
2665
- When to use:
2666
- - Questions with multiple subparts that need (a), (b), (c) labeling
2667
- - Vector math problems with repeated calculations
2668
- - Any math problem where subparts should be aligned
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)
2669
2254
 
2670
- Features:
2671
- - Automatic subpart labeling with (a), (b), (c), etc.
2672
- - Proper LaTeX alignat* formatting for PDF
2673
- - HTML fallback with organized layout
2674
- - Flexible content support (equations, matrices, etc.)
2255
+ def render_latex(self, **kwargs):
2256
+ return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.blank_length}}} {self.unit}".strip()
2675
2257
 
2676
- Example:
2677
- # Create subparts for vector dot products
2678
- subparts = [
2679
- (ContentAST.Matrix([[1], [2]]), "\\cdot", ContentAST.Matrix([[3], [4]])),
2680
- (ContentAST.Matrix([[5], [6]]), "\\cdot", ContentAST.Matrix([[7], [8]]))
2681
- ]
2682
- repeated_part = ContentAST.RepeatedProblemPart(subparts)
2683
- body.add_element(repeated_part)
2684
- """
2685
- def __init__(self, subpart_contents):
2686
- """
2687
- Create a repeated problem part with multiple subquestions.
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)"
2688
2262
 
2689
- Args:
2690
- subpart_contents: List of content for each subpart.
2691
- Each item can be:
2692
- - A string (rendered as equation)
2693
- - A ContentAST.Element
2694
- - A tuple/list of elements to be joined
2695
- """
2696
- super().__init__()
2697
- self.subpart_contents = subpart_contents
2263
+ label_part = f"{self.label}:" if self.label else ""
2264
+ unit_part = f" {self.unit}" if self.unit else ""
2698
2265
 
2699
- def render_markdown(self, **kwargs):
2700
- result = []
2701
- for i, content in enumerate(self.subpart_contents):
2702
- letter = chr(ord('a') + i) # Convert to (a), (b), (c), etc.
2703
- if isinstance(content, str):
2704
- result.append(f"({letter}) {content}")
2705
- elif isinstance(content, (list, tuple)):
2706
- content_str = " ".join(str(item) for item in content)
2707
- result.append(f"({letter}) {content_str}")
2708
- else:
2709
- result.append(f"({letter}) {str(content)}")
2710
- return "\n\n".join(result)
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
+ )
2711
2278
 
2712
- def render_html(self, **kwargs):
2713
- result = []
2714
- for i, content in enumerate(self.subpart_contents):
2715
- letter = chr(ord('a') + i)
2716
- if isinstance(content, str):
2717
- result.append(f"<p>({letter}) {content}</p>")
2718
- elif isinstance(content, (list, tuple)):
2719
- rendered_items = []
2720
- for item in content:
2721
- if hasattr(item, 'render'):
2722
- rendered_items.append(item.render('html', **kwargs))
2723
- else:
2724
- rendered_items.append(str(item))
2725
- content_str = " ".join(rendered_items)
2726
- result.append(f"<p>({letter}) {content_str}</p>")
2727
- else:
2728
- if hasattr(content, 'render'):
2729
- content_str = content.render('html', **kwargs)
2730
- else:
2731
- content_str = str(content)
2732
- result.append(f"<p>({letter}) {content_str}</p>")
2733
- return "\n".join(result)
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
+ )
2734
2288
 
2735
- def render_latex(self, **kwargs):
2736
- if not self.subpart_contents:
2737
- return ""
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)))
2738
2316
 
2739
- # Start alignat environment - use 2 columns for alignment
2740
- result = [r"\begin{alignat*}{2}"]
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()
2741
2332
 
2742
- for i, content in enumerate(self.subpart_contents):
2743
- letter = chr(ord('a') + i)
2744
- spacing = r"\\[6pt]" if i < len(self.subpart_contents) - 1 else r" \\"
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'))
2745
2340
 
2746
- if isinstance(content, str):
2747
- # Treat as raw LaTeX equation content
2748
- result.append(f"({letter})\\;& {content} &=&\\; {spacing}")
2749
- elif isinstance(content, (list, tuple)):
2750
- # Join multiple elements (e.g., matrix, operator, matrix)
2751
- rendered_items = []
2752
- for item in content:
2753
- if hasattr(item, 'render'):
2754
- rendered_items.append(item.render('latex', **kwargs))
2755
- elif isinstance(item, str):
2756
- rendered_items.append(item)
2757
- else:
2758
- rendered_items.append(str(item))
2759
- content_str = " ".join(rendered_items)
2760
- result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
2761
- else:
2762
- # Single element (ContentAST element or string)
2763
- if hasattr(content, 'render'):
2764
- content_str = content.render('latex', **kwargs)
2341
+ # Simple fraction
2342
+ if allow_simple_fraction:
2343
+ fr = f.limit_denominator(max_denominator)
2344
+ if fr == f:
2345
+ a, b = fr.numerator, fr.denominator
2346
+ if fr.denominator > 1:
2347
+ outs.add(f"{a}/{b}")
2348
+ if include_spaces:
2349
+ outs.add(f"{a} / {b}")
2350
+ if allow_mixed and b != 1 and abs(a) > b:
2351
+ sign = '-' if a < 0 else ''
2352
+ A = abs(a)
2353
+ whole, rem = divmod(A, b)
2354
+ outs.add(f"{sign}{whole} {rem}/{b}")
2765
2355
  else:
2766
- content_str = str(content)
2767
- result.append(f"({letter})\\;& {content_str} &=&\\; {spacing}")
2768
-
2769
- result.append(r"\end{alignat*}")
2770
- return "\n".join(result)
2356
+ return sorted(outs, key=lambda s: (len(s), s))
2771
2357
 
2772
- class OnlyLatex(Container):
2773
- """
2774
- Container element that only renders content in LaTeX/PDF output format.
2358
+ # Fixed-decimal form
2359
+ q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2360
+ d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2361
+ outs.add(format(d, 'f'))
2775
2362
 
2776
- Use this when you need LaTeX-specific content that should not appear
2777
- in HTML/Canvas or Markdown outputs. Content is completely hidden
2778
- from non-LaTeX formats.
2363
+ # Trimmed decimal
2364
+ if ContentAST.Answer.DEFAULT_ROUNDING_DIGITS:
2365
+ q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2366
+ d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
2367
+ s = format(d, 'f').rstrip('0').rstrip('.')
2368
+ if s.startswith('.'):
2369
+ s = '0' + s
2370
+ if s == '-0':
2371
+ s = '0'
2372
+ outs.add(s)
2779
2373
 
2780
- When to use:
2781
- - LaTeX-specific formatting that has no HTML equivalent
2782
- - PDF-only instructions or content
2783
- - Complex LaTeX commands that break HTML rendering
2374
+ return sorted(outs, key=lambda s: (len(s), s))
2375
+
2376
+ @staticmethod
2377
+ def fix_negative_zero(x):
2378
+ """Fix -0.0 display issue."""
2379
+ return 0.0 if x == 0 else x
2784
2380
 
2785
- Example:
2786
- # LaTeX-only spacing or formatting
2787
- latex_only = ContentAST.OnlyLatex()
2788
- latex_only.add_element(ContentAST.Text("\\newpage"))
2789
2381
 
2790
- # Add to main content - only appears in PDF
2791
- body.add_element(latex_only)
2382
+ class AnswerTypes:
2383
+ # Multibase answers that can accept either hex, binary or decimal
2384
+ class MultiBase(ContentAST.Answer):
2385
+ """
2386
+ These are answers that can accept answers in any sort of format, and default to displaying in hex when written out.
2387
+ This will be the parent class for Binary, Hex, and Integer answers most likely.
2792
2388
  """
2389
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2793
2390
 
2794
- def render(self, output_format: ContentAST.OutputFormat, **kwargs):
2795
- if output_format not in ("latex", "typst"):
2796
- return ""
2797
- return super().render(output_format=output_format, **kwargs)
2391
+ canvas_answers = [
2392
+ {
2393
+ "blank_id": self.key,
2394
+ "answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
2395
+ "answer_weight": 100 if self.correct else 0,
2396
+ },
2397
+ {
2398
+ "blank_id": self.key,
2399
+ "answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
2400
+ "answer_weight": 100 if self.correct else 0,
2401
+ },
2402
+ {
2403
+ "blank_id": self.key,
2404
+ "answer_text": f"{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
2405
+ "answer_weight": 100 if self.correct else 0,
2406
+ },
2407
+ {
2408
+ "blank_id": self.key,
2409
+ "answer_text": f"0x{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
2410
+ "answer_weight": 100 if self.correct else 0,
2411
+ },
2412
+ {
2413
+ "blank_id": self.key,
2414
+ "answer_text": f"{self.value}",
2415
+ "answer_weight": 100 if self.correct else 0,
2416
+ },
2417
+ ]
2418
+
2419
+ return canvas_answers
2420
+
2421
+ def get_display_string(self) -> str:
2422
+ # This is going to be the default for multi-base answers, but may change later.
2423
+ hex_digits = (self.length // 4) + 1 if self.length is not None else 0
2424
+ return f"0x{self.value:0{hex_digits}X}"
2798
2425
 
2799
- class OnlyHtml(Container):
2800
- """
2801
- Container element that only renders content in HTML/Canvas output format.
2426
+ class Hex(MultiBase):
2427
+ pass
2428
+
2429
+ class Binary(MultiBase):
2430
+ def get_display_string(self) -> str:
2431
+ return f"0b{self.value:0{self.length if self.length is not None else 0}b}"
2432
+
2433
+ class Decimal(MultiBase):
2434
+ def get_display_string(self) -> str:
2435
+ return f"{self.value:0{self.length if self.length is not None else 0}}"
2802
2436
 
2803
- Use this when you need HTML-specific content that should not appear
2804
- in LaTeX/PDF or Markdown outputs. Content is completely hidden
2805
- from non-HTML formats.
2437
+ # Concrete type answers
2438
+ class Float(ContentAST.Answer):
2439
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2440
+ if single_answer:
2441
+ canvas_answers = [
2442
+ {
2443
+ "numerical_answer_type": "exact_answer",
2444
+ "answer_text": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
2445
+ "answer_exact": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
2446
+ "answer_error_margin": 0.1,
2447
+ "answer_weight": 100 if self.correct else 0,
2448
+ }
2449
+ ]
2450
+ else:
2451
+ # Use the accepted_strings helper
2452
+ answer_strings = ContentAST.Answer.accepted_strings(
2453
+ self.value,
2454
+ allow_integer=True,
2455
+ allow_simple_fraction=True,
2456
+ max_denominator=60,
2457
+ allow_mixed=True,
2458
+ include_spaces=False,
2459
+ include_fixed_even_if_integer=True
2460
+ )
2461
+
2462
+ canvas_answers = [
2463
+ {
2464
+ "blank_id": self.key,
2465
+ "answer_text": answer_string,
2466
+ "answer_weight": 100 if self.correct else 0,
2467
+ }
2468
+ for answer_string in answer_strings
2469
+ ]
2470
+ return canvas_answers
2471
+
2472
+ def get_display_string(self) -> str:
2473
+ rounded = round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
2474
+ return f"{self.fix_negative_zero(rounded)}"
2475
+
2476
+ class Int(ContentAST.Answer):
2806
2477
 
2807
- When to use:
2808
- - Canvas-specific instructions or formatting
2809
- - HTML-only interactive elements
2810
- - Content that doesn't translate well to PDF
2478
+ # Canvas export methods (from misc.Answer)
2479
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2480
+ canvas_answers = [
2481
+ {
2482
+ "blank_id": self.key,
2483
+ "answer_text": str(int(self.value)),
2484
+ "answer_weight": 100 if self.correct else 0,
2485
+ }
2486
+ ]
2487
+ return canvas_answers
2811
2488
 
2812
- Example:
2813
- # HTML-only instructions
2814
- html_only = ContentAST.OnlyHtml()
2815
- html_only.add_element(ContentAST.Text("Click submit when done"))
2489
+ # Open Ended
2490
+ class OpenEnded(ContentAST.Answer):
2491
+ def __init__(self, *args, **kwargs):
2492
+ super().__init__(*args, **kwargs)
2493
+ self.kind=ContentAST.Answer.CanvasAnswerKind.ESSAY
2494
+
2495
+ class String(ContentAST.Answer):
2496
+ pass
2497
+
2498
+ class List(ContentAST.Answer):
2499
+ def __init__(self, order_matters=True, *args, **kwargs):
2500
+ super().__init__(*args, **kwargs)
2501
+ self.order_matters = order_matters
2502
+
2503
+ def get_for_canvas(self, single_answer=False):
2504
+ if self.order_matters:
2505
+ canvas_answers = [
2506
+ {
2507
+ "blank_id": self.key,
2508
+ "answer_text": ', '.join(map(str, self.value)),
2509
+ "answer_weight": 100 if self.correct else 0,
2510
+ },
2511
+ {
2512
+ "blank_id": self.key,
2513
+ "answer_text": ','.join(map(str, self.value)),
2514
+ "answer_weight": 100 if self.correct else 0,
2515
+ }
2516
+ ]
2517
+ else:
2518
+ canvas_answers = []
2519
+
2520
+ # With spaces
2521
+ canvas_answers.extend([
2522
+ {
2523
+ "blank_id": self.key,
2524
+ "answer_text": ', '.join(map(str, possible_state)),
2525
+ "answer_weight": 100 if self.correct else 0,
2526
+ }
2527
+ for possible_state in itertools.permutations(self.value)
2528
+ ])
2529
+
2530
+ # Without spaces
2531
+ canvas_answers.extend([
2532
+ {
2533
+ "blank_id": self.key,
2534
+ "answer_text": ','.join(map(str, possible_state)),
2535
+ "answer_weight": 100 if self.correct else 0,
2536
+ }
2537
+ for possible_state in itertools.permutations(self.value)
2538
+ ])
2539
+ return canvas_answers
2816
2540
 
2817
- # Add to main content - only appears in Canvas
2818
- body.add_element(html_only)
2541
+ def get_display_string(self) -> str:
2542
+ """Get the formatted display string for this answer (for grading/answer keys)."""
2543
+ return ", ".join(str(v) for v in self.value)
2544
+
2545
+ # Math types
2546
+ class Vector(ContentAST.Answer):
2547
+ """
2548
+ These are self-contained vectors that will go in a single answer block
2819
2549
  """
2820
2550
 
2821
- def render(self, output_format, **kwargs):
2822
- if output_format != "html":
2823
- return ""
2824
- return super().render(output_format, **kwargs)
2551
+ # Canvas export methods (from misc.Answer)
2552
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2553
+ # Get all answer variations
2554
+ answer_variations = [
2555
+ ContentAST.Answer.accepted_strings(dimension_value)
2556
+ for dimension_value in self.value
2557
+ ]
2558
+
2559
+ canvas_answers = []
2560
+ for combination in itertools.product(*answer_variations):
2561
+ # Add without anything surrounding
2562
+ canvas_answers.extend([
2563
+ {
2564
+ "blank_id": self.key,
2565
+ "answer_weight": 100 if self.correct else 0,
2566
+ "answer_text": f"{', '.join(combination)}",
2567
+ },
2568
+ {
2569
+ "blank_id": self.key,
2570
+ "answer_weight": 100 if self.correct else 0,
2571
+ "answer_text": f"{','.join(combination)}",
2572
+ },
2573
+ # Add parentheses format
2574
+ {
2575
+ "blank_id": self.key,
2576
+ "answer_weight": 100 if self.correct else 0,
2577
+ "answer_text": f"({', '.join(list(combination))})",
2578
+ },
2579
+ {
2580
+ "blank_id": self.key,
2581
+ "answer_weight": 100 if self.correct else 0,
2582
+ "answer_text": f"({','.join(list(combination))})",
2583
+ },
2584
+ # Add square brackets
2585
+ {
2586
+ "blank_id": self.key,
2587
+ "answer_weight": 100 if self.correct else 0,
2588
+ "answer_text": f"[{', '.join(list(combination))}]",
2589
+ },
2590
+ {
2591
+ "blank_id": self.key,
2592
+ "answer_weight": 100 if self.correct else 0,
2593
+ "answer_text": f"[{','.join(list(combination))}]",
2594
+ }
2595
+ ])
2596
+ return canvas_answers
2597
+
2598
+ def get_display_string(self) -> str:
2599
+ return ", ".join(
2600
+ str(self.fix_negative_zero(round(v, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS))).rstrip('0').rstrip('.') for v in self.value
2601
+ )
2602
+
2603
+ class CompoundAnswers(ContentAST.Answer):
2604
+ pass
2605
+ """
2606
+ Going forward, this might make a lot of sense to have a SubAnswer class that we can iterate over.
2607
+ We would convert into this shared format and just iterate over it whenever we need to.
2608
+ """
2609
+
2610
+ class Matrix(CompoundAnswers):
2611
+ """
2612
+ Matrix answers generate multiple blank_ids (e.g., M_0_0, M_0_1, M_1_0, M_1_1).
2613
+ """
2614
+
2615
+ def __init__(self, value, *args, **kwargs):
2616
+ super().__init__(value=value, *args, **kwargs)
2617
+
2618
+ self.data = [
2619
+ [
2620
+ AnswerTypes.Float(
2621
+ value=self.value[i, j],
2622
+ blank_length=5
2623
+ )
2624
+ for j in range(self.value.shape[1])
2625
+ ]
2626
+ for i in range(self.value.shape[0])
2627
+ ]
2628
+
2629
+ def get_for_canvas(self, single_answer=False) -> List[dict]:
2630
+ """Generate Canvas answers for each matrix element."""
2631
+ canvas_answers = []
2632
+
2633
+ for sub_answer in itertools.chain.from_iterable(self.data):
2634
+ canvas_answers.extend(sub_answer.get_for_canvas())
2635
+
2636
+ return canvas_answers
2637
+
2638
+ def render(self, *args, **kwargs) -> str:
2639
+ table = ContentAST.Table(self.data)
2640
+
2641
+ if self.label:
2642
+ return ContentAST.Container(
2643
+ [
2644
+ ContentAST.Text(f"{self.label} = "),
2645
+ table
2646
+ ]
2647
+ ).render(*args, **kwargs)
2648
+ return table.render(*args, **kwargs)
2649
+
2825
2650