diff --git a/contentcuration/contentcuration/tests/utils/test_exercise_creation.py b/contentcuration/contentcuration/tests/utils/test_exercise_creation.py index deceb2d980..f57977e43e 100644 --- a/contentcuration/contentcuration/tests/utils/test_exercise_creation.py +++ b/contentcuration/contentcuration/tests/utils/test_exercise_creation.py @@ -1568,7 +1568,7 @@ def test_exercise_with_image(self): - + """ @@ -1579,7 +1579,103 @@ def test_exercise_with_image(self): self._normalize_xml(actual_manifest_xml), ) - self.assertEqual(exercise_file.checksum, "51ba0d6e3c7f30239265c5294abe6ac5") + self.assertEqual(exercise_file.checksum, "8df26b0c7009ae84fe148cceda8e0138") + + def test_image_resizing(self): + # Create a base image file + base_image = fileobj_exercise_image(size=(400, 300), color="blue") + base_image_url = exercises.CONTENT_STORAGE_FORMAT.format(base_image.filename()) + + # For questions, test multiple sizes of the same image + question_text = ( + f"First resized image: \n\n" + f"Second resized image (same): \n\n" + f"Third resized image (different): " + ) + answers = [{"answer": "Answer A", "correct": True, "order": 1}] + hints = [{"hint": "Hint text", "order": 1}] + + # Create the assessment item + item_type = exercises.SINGLE_SELECTION + + item = self._create_assessment_item(item_type, question_text, answers, hints) + + # Associate the image with the assessment item + base_image.assessment_item = item + base_image.save() + + # Create exercise data + exercise_data = { + "mastery_model": exercises.M_OF_N, + "randomize": True, + "n": 2, + "m": 1, + "all_assessment_items": [item.assessment_id], + "assessment_mapping": {item.assessment_id: item_type}, + } + + # Create the Perseus exercise + self._create_qti_zip(exercise_data) + + exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) + zip_file = self._validate_qti_zip_structure(exercise_file) + + # Get all image files in the zip + image_files = [ + name for name in zip_file.namelist() if name.startswith("items/images/") + ] + + # Verify we have exactly 2 image files (one for each unique size) + # We should have one at 200x150 and one at 100x75 + self.assertEqual( + len(image_files), + 2, + f"Expected 2 resized images, found {len(image_files)}: {image_files}", + ) + + # The original image should not be present unless it was referenced without resizing + original_image_name = f"images/{base_image.filename()}" + self.assertNotIn( + original_image_name, + zip_file.namelist(), + "Original image should not be included when only resized versions are used", + ) + + qti_id = hex_to_qti_id(item.assessment_id) + + # Check the QTI XML for mathematical content conversion to MathML + expected_item_file = f"items/{qti_id}.xml" + actual_item_xml = zip_file.read(expected_item_file).decode("utf-8") + + # Expected QTI item XML content with MathML conversion + expected_item_xml = f""" + + + + choice_0 + + + + + + + First resized image: + Second resized image (same): + Third resized image (different): + + + Answer A + + + + + """ + + # Compare normalized XML + self.assertEqual( + self._normalize_xml(expected_item_xml), + self._normalize_xml(actual_item_xml), + ) def test_question_with_mathematical_content(self): """Test QTI generation for questions containing mathematical formulas converted to MathML""" diff --git a/contentcuration/contentcuration/utils/assessment/base.py b/contentcuration/contentcuration/utils/assessment/base.py index 0f668920a0..58ca5f0449 100644 --- a/contentcuration/contentcuration/utils/assessment/base.py +++ b/contentcuration/contentcuration/utils/assessment/base.py @@ -47,6 +47,8 @@ class ExerciseArchiveGenerator(ABC): ZIP_DATE_TIME = (2015, 10, 21, 7, 28, 0) ZIP_COMPRESS_TYPE = zipfile.ZIP_DEFLATED ZIP_COMMENT = "".encode() + # Whether to keep width/height in image refs + RETAIN_IMAGE_DIMENSIONS = True @property @abstractmethod @@ -68,12 +70,13 @@ def get_image_file_path(self): """ pass + @abstractmethod def get_image_ref_prefix(self): """ - A value to insert in front of the image file path - this is needed for Perseus to properly - find all image file paths in the frontend. + A value to insert in front of the image path - this adds both the special placeholder + that our Perseus viewer uses to find images, and the relative path to the images directory. """ - return "" + pass @abstractmethod def create_assessment_item(self, assessment_item, processed_data): @@ -203,6 +206,11 @@ def _replace_filename_in_match( start, end = img_match.span() old_match = content[start:end] new_match = old_match.replace(old_filename, new_filename) + if not self.RETAIN_IMAGE_DIMENSIONS: + # Remove dimensions from image ref + new_match = re.sub( + rf"{new_filename}\s=([0-9\.]+)x([0-9\.]+)", new_filename, new_match + ) return content[:start] + new_match + content[end:] def _is_valid_image_filename(self, filename): @@ -231,7 +239,7 @@ def _is_valid_image_filename(self, filename): def process_image_strings(self, content): new_file_path = self.get_image_file_path() - new_image_path = f"{self.get_image_ref_prefix()}{new_file_path}" + new_image_path = self.get_image_ref_prefix() image_list = [] processed_files = [] for img_match in re.finditer(image_pattern, content): diff --git a/contentcuration/contentcuration/utils/assessment/perseus.py b/contentcuration/contentcuration/utils/assessment/perseus.py index 7ba4e1ce6f..e96ebbae49 100644 --- a/contentcuration/contentcuration/utils/assessment/perseus.py +++ b/contentcuration/contentcuration/utils/assessment/perseus.py @@ -119,7 +119,7 @@ def get_image_file_path(self): return "images" def get_image_ref_prefix(self): - return f"${exercises.IMG_PLACEHOLDER}/" + return f"${exercises.IMG_PLACEHOLDER}/images" def handle_before_assessment_items(self): exercise_context = { diff --git a/contentcuration/contentcuration/utils/assessment/qti/archive.py b/contentcuration/contentcuration/utils/assessment/qti/archive.py index 4a29f20c84..682dbaab57 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/archive.py +++ b/contentcuration/contentcuration/utils/assessment/qti/archive.py @@ -64,6 +64,8 @@ class QTIExerciseGenerator(ExerciseArchiveGenerator): file_format = "zip" preset = format_presets.QTI_ZIP + # Our markdown parser does not handle width/height in image refs + RETAIN_IMAGE_DIMENSIONS = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -73,6 +75,13 @@ def get_image_file_path(self) -> str: """Get the file path for QTI assessment items.""" return "items/images" + def get_image_ref_prefix(self): + """ + Because we put items in a subdirectory, we need to prefix the image paths + with the relative path to the images directory. + """ + return "images" + def _create_html_content_from_text(self, text: str) -> FlowContentList: """Convert text content to QTI HTML flow content.""" if not text.strip():
First resized image:
Second resized image (same):
Third resized image (different):
Answer A