Skip to content

Commit d16d580

Browse files
authored
Merge pull request #5316 from rtibbles/images_in_qti
Fix image export in QTI exercise publishing
2 parents a088de7 + f8a831a commit d16d580

File tree

4 files changed

+120
-7
lines changed

4 files changed

+120
-7
lines changed

contentcuration/contentcuration/tests/utils/test_exercise_creation.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,7 +1568,7 @@ def test_exercise_with_image(self):
15681568
<resources>
15691569
<resource identifier="KERGqqiIiu7szM8zMRETd3Q" type="imsqti_item_xmlv3p0" href="items/KERGqqiIiu7szM8zMRETd3Q.xml">
15701570
<file href="items/KERGqqiIiu7szM8zMRETd3Q.xml" />
1571-
<file href="{image_path}" />
1571+
<file href="images/{image_file.filename()}" />
15721572
</resource>
15731573
</resources>
15741574
</manifest>"""
@@ -1579,7 +1579,103 @@ def test_exercise_with_image(self):
15791579
self._normalize_xml(actual_manifest_xml),
15801580
)
15811581

1582-
self.assertEqual(exercise_file.checksum, "51ba0d6e3c7f30239265c5294abe6ac5")
1582+
self.assertEqual(exercise_file.checksum, "8df26b0c7009ae84fe148cceda8e0138")
1583+
1584+
def test_image_resizing(self):
1585+
# Create a base image file
1586+
base_image = fileobj_exercise_image(size=(400, 300), color="blue")
1587+
base_image_url = exercises.CONTENT_STORAGE_FORMAT.format(base_image.filename())
1588+
1589+
# For questions, test multiple sizes of the same image
1590+
question_text = (
1591+
f"First resized image: ![shape1]({base_image_url} =200x150)\n\n"
1592+
f"Second resized image (same): ![shape2]({base_image_url} =200x150)\n\n"
1593+
f"Third resized image (different): ![shape3]({base_image_url} =100x75)"
1594+
)
1595+
answers = [{"answer": "Answer A", "correct": True, "order": 1}]
1596+
hints = [{"hint": "Hint text", "order": 1}]
1597+
1598+
# Create the assessment item
1599+
item_type = exercises.SINGLE_SELECTION
1600+
1601+
item = self._create_assessment_item(item_type, question_text, answers, hints)
1602+
1603+
# Associate the image with the assessment item
1604+
base_image.assessment_item = item
1605+
base_image.save()
1606+
1607+
# Create exercise data
1608+
exercise_data = {
1609+
"mastery_model": exercises.M_OF_N,
1610+
"randomize": True,
1611+
"n": 2,
1612+
"m": 1,
1613+
"all_assessment_items": [item.assessment_id],
1614+
"assessment_mapping": {item.assessment_id: item_type},
1615+
}
1616+
1617+
# Create the Perseus exercise
1618+
self._create_qti_zip(exercise_data)
1619+
1620+
exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP)
1621+
zip_file = self._validate_qti_zip_structure(exercise_file)
1622+
1623+
# Get all image files in the zip
1624+
image_files = [
1625+
name for name in zip_file.namelist() if name.startswith("items/images/")
1626+
]
1627+
1628+
# Verify we have exactly 2 image files (one for each unique size)
1629+
# We should have one at 200x150 and one at 100x75
1630+
self.assertEqual(
1631+
len(image_files),
1632+
2,
1633+
f"Expected 2 resized images, found {len(image_files)}: {image_files}",
1634+
)
1635+
1636+
# The original image should not be present unless it was referenced without resizing
1637+
original_image_name = f"images/{base_image.filename()}"
1638+
self.assertNotIn(
1639+
original_image_name,
1640+
zip_file.namelist(),
1641+
"Original image should not be included when only resized versions are used",
1642+
)
1643+
1644+
qti_id = hex_to_qti_id(item.assessment_id)
1645+
1646+
# Check the QTI XML for mathematical content conversion to MathML
1647+
expected_item_file = f"items/{qti_id}.xml"
1648+
actual_item_xml = zip_file.read(expected_item_file).decode("utf-8")
1649+
1650+
# Expected QTI item XML content with MathML conversion
1651+
expected_item_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
1652+
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" identifier="{qti_id}" title="Test QTI Exercise 1" adaptive="false" time-dependent="false" language="en-US" tool-name="kolibri" tool-version="0.1">
1653+
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">
1654+
<qti-correct-response>
1655+
<qti-value>choice_0</qti-value>
1656+
</qti-correct-response>
1657+
</qti-response-declaration>
1658+
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />
1659+
<qti-item-body>
1660+
<qti-choice-interaction response-identifier="RESPONSE" shuffle="true" max-choices="1" min-choices="0" orientation="vertical">
1661+
<qti-prompt>
1662+
<p>First resized image: <img alt="shape1" src="images/b8f3062ca5795e39ff813958296b4884.jpg" /></p>
1663+
<p>Second resized image (same): <img alt="shape2" src="images/b8f3062ca5795e39ff813958296b4884.jpg" /></p>
1664+
<p>Third resized image (different): <img alt="shape3" src="images/abb0589d29a3852a5ebfd2726a832761.jpg" /></p>
1665+
</qti-prompt>
1666+
<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false">
1667+
<p>Answer A</p>
1668+
</qti-simple-choice>
1669+
</qti-choice-interaction>
1670+
</qti-item-body>
1671+
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
1672+
</qti-assessment-item>"""
1673+
1674+
# Compare normalized XML
1675+
self.assertEqual(
1676+
self._normalize_xml(expected_item_xml),
1677+
self._normalize_xml(actual_item_xml),
1678+
)
15831679

15841680
def test_question_with_mathematical_content(self):
15851681
"""Test QTI generation for questions containing mathematical formulas converted to MathML"""

contentcuration/contentcuration/utils/assessment/base.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class ExerciseArchiveGenerator(ABC):
4747
ZIP_DATE_TIME = (2015, 10, 21, 7, 28, 0)
4848
ZIP_COMPRESS_TYPE = zipfile.ZIP_DEFLATED
4949
ZIP_COMMENT = "".encode()
50+
# Whether to keep width/height in image refs
51+
RETAIN_IMAGE_DIMENSIONS = True
5052

5153
@property
5254
@abstractmethod
@@ -68,12 +70,13 @@ def get_image_file_path(self):
6870
"""
6971
pass
7072

73+
@abstractmethod
7174
def get_image_ref_prefix(self):
7275
"""
73-
A value to insert in front of the image file path - this is needed for Perseus to properly
74-
find all image file paths in the frontend.
76+
A value to insert in front of the image path - this adds both the special placeholder
77+
that our Perseus viewer uses to find images, and the relative path to the images directory.
7578
"""
76-
return ""
79+
pass
7780

7881
@abstractmethod
7982
def create_assessment_item(self, assessment_item, processed_data):
@@ -203,6 +206,11 @@ def _replace_filename_in_match(
203206
start, end = img_match.span()
204207
old_match = content[start:end]
205208
new_match = old_match.replace(old_filename, new_filename)
209+
if not self.RETAIN_IMAGE_DIMENSIONS:
210+
# Remove dimensions from image ref
211+
new_match = re.sub(
212+
rf"{new_filename}\s=([0-9\.]+)x([0-9\.]+)", new_filename, new_match
213+
)
206214
return content[:start] + new_match + content[end:]
207215

208216
def _is_valid_image_filename(self, filename):
@@ -231,7 +239,7 @@ def _is_valid_image_filename(self, filename):
231239

232240
def process_image_strings(self, content):
233241
new_file_path = self.get_image_file_path()
234-
new_image_path = f"{self.get_image_ref_prefix()}{new_file_path}"
242+
new_image_path = self.get_image_ref_prefix()
235243
image_list = []
236244
processed_files = []
237245
for img_match in re.finditer(image_pattern, content):

contentcuration/contentcuration/utils/assessment/perseus.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def get_image_file_path(self):
119119
return "images"
120120

121121
def get_image_ref_prefix(self):
122-
return f"${exercises.IMG_PLACEHOLDER}/"
122+
return f"${exercises.IMG_PLACEHOLDER}/images"
123123

124124
def handle_before_assessment_items(self):
125125
exercise_context = {

contentcuration/contentcuration/utils/assessment/qti/archive.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ class QTIExerciseGenerator(ExerciseArchiveGenerator):
6464

6565
file_format = "zip"
6666
preset = format_presets.QTI_ZIP
67+
# Our markdown parser does not handle width/height in image refs
68+
RETAIN_IMAGE_DIMENSIONS = False
6769

6870
def __init__(self, *args, **kwargs):
6971
super().__init__(*args, **kwargs)
@@ -73,6 +75,13 @@ def get_image_file_path(self) -> str:
7375
"""Get the file path for QTI assessment items."""
7476
return "items/images"
7577

78+
def get_image_ref_prefix(self):
79+
"""
80+
Because we put items in a subdirectory, we need to prefix the image paths
81+
with the relative path to the images directory.
82+
"""
83+
return "images"
84+
7685
def _create_html_content_from_text(self, text: str) -> FlowContentList:
7786
"""Convert text content to QTI HTML flow content."""
7887
if not text.strip():

0 commit comments

Comments
 (0)