Skip to content

Commit f8a831a

Browse files
committed
Remove image dimensions during resizing for QTI, as image dimensions are KA only markdown elements.
Ensure relative path is used during QTI item generation.
1 parent f2e30e7 commit f8a831a

4 files changed

Lines changed: 120 additions & 7 deletions

File tree

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)