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.
- QuizGenerator/contentast.py +1056 -1231
- QuizGenerator/generate.py +174 -2
- QuizGenerator/misc.py +0 -6
- QuizGenerator/mixins.py +7 -8
- QuizGenerator/premade_questions/basic.py +3 -3
- QuizGenerator/premade_questions/cst334/languages.py +45 -51
- QuizGenerator/premade_questions/cst334/math_questions.py +9 -10
- QuizGenerator/premade_questions/cst334/memory_questions.py +39 -56
- QuizGenerator/premade_questions/cst334/persistence_questions.py +12 -27
- QuizGenerator/premade_questions/cst334/process.py +11 -22
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +11 -11
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +7 -7
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +6 -6
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +2 -2
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +15 -19
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +149 -442
- QuizGenerator/premade_questions/cst463/models/attention.py +7 -8
- QuizGenerator/premade_questions/cst463/models/cnns.py +6 -7
- QuizGenerator/premade_questions/cst463/models/rnns.py +6 -6
- QuizGenerator/premade_questions/cst463/models/text.py +7 -8
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +5 -9
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +22 -22
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +25 -25
- QuizGenerator/question.py +13 -14
- {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/METADATA +1 -1
- {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/RECORD +29 -29
- {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/WHEEL +0 -0
- {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.5.1.dist-info → quizgenerator-0.6.1.dist-info}/licenses/LICENSE +0 -0
QuizGenerator/contentast.py
CHANGED
|
@@ -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,
|
|
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
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
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
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
"
|
|
1620
|
-
"
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
"
|
|
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
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
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
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
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
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
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
|
|
1760
|
-
"""
|
|
1761
|
-
|
|
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
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
1820
|
+
def render_latex(self, **kwargs):
|
|
1821
|
+
if not self.tables:
|
|
1822
|
+
return ""
|
|
1773
1823
|
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1783
|
-
|
|
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
|
-
|
|
1790
|
-
|
|
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
|
-
|
|
1793
|
-
|
|
1844
|
+
# Render the table
|
|
1845
|
+
table_latex = table.render("latex", **kwargs)
|
|
1846
|
+
result.append(table_latex)
|
|
1794
1847
|
|
|
1795
|
-
|
|
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
|
-
|
|
1801
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
1823
|
-
|
|
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
|
-
|
|
1827
|
-
|
|
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
|
-
|
|
1866
|
+
num_tables = len(self.tables)
|
|
1830
1867
|
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
"
|
|
1835
|
-
|
|
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
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
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
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1885
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
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
|
-
|
|
1916
|
-
|
|
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
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
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
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
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
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
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
|
-
|
|
1968
|
-
|
|
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
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
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
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
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
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
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
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
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
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
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
|
-
|
|
2024
|
+
def render_latex(self, **kwargs):
|
|
2025
|
+
if not self.subpart_contents:
|
|
2026
|
+
return ""
|
|
2089
2027
|
|
|
2090
|
-
|
|
2091
|
-
|
|
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
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
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
|
-
|
|
2130
|
-
|
|
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
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
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
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
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
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
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
|
|
2088
|
+
class OnlyHtml(Container):
|
|
2189
2089
|
"""
|
|
2190
|
-
|
|
2090
|
+
Container element that only renders content in HTML/Canvas output format.
|
|
2191
2091
|
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
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
|
-
-
|
|
2198
|
-
-
|
|
2199
|
-
-
|
|
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
|
-
#
|
|
2209
|
-
|
|
2210
|
-
|
|
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
|
-
#
|
|
2217
|
-
|
|
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
|
-
|
|
2234
|
-
|
|
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
|
|
2115
|
+
class Answer(Leaf):
|
|
2243
2116
|
"""
|
|
2244
|
-
|
|
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
|
-
|
|
2251
|
-
|
|
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
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
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
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2604
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
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
|
-
|
|
2613
|
-
|
|
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
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
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
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
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
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2644
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
2657
|
-
|
|
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
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
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
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
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
|
-
|
|
2671
|
-
|
|
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
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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
|
-
|
|
2690
|
-
|
|
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
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
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
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
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
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
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
|
-
|
|
2740
|
-
|
|
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
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
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
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
if
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
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
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
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
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
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
|
-
|
|
2791
|
-
|
|
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
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
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
|
|
2800
|
-
|
|
2801
|
-
|
|
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
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
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
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
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
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
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
|
-
|
|
2818
|
-
|
|
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
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
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
|
|