Skip to content

Commit 8a71d91

Browse files
rtibblesbotclaude
andcommitted
fix: set the QTI item language on the real root past prolog comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 954d27f commit 8a71d91

2 files changed

Lines changed: 122 additions & 1 deletion

File tree

‎contentcuration/contentcuration/tests/utils/qti/test_media.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pytest
2+
13
from contentcuration.tests.utils.qti.test_validation import VALID_CHOICE_ITEM
24
from contentcuration.utils.assessment.qti.media import get_qti_media_references
35
from contentcuration.utils.assessment.qti.media import rewrite_qti_media_paths
@@ -95,6 +97,11 @@ def test_rewrite_ignores_values_not_in_mapping():
9597
)
9698

9799

100+
ITEM_WITH_ES = ITEM_WITHOUT_LANGUAGE.replace(
101+
'time-dependent="false">', 'time-dependent="false" xml:lang="es">'
102+
)
103+
104+
98105
def test_set_language_adds_it_when_the_item_has_none():
99106
result = set_qti_item_language(ITEM_WITHOUT_LANGUAGE, "es")
100107
assert 'xml:lang="es"' in result
@@ -187,3 +194,59 @@ def test_strip_studio_attributes_ignores_lookalikes_in_values_and_text():
187194
'<p title="a > b data-studio-prompt=\'\'" data-other="1">data-studio-prompt=""</p>',
188195
)
189196
assert strip_studio_attributes(tricky) == tricky
197+
198+
199+
def test_set_language_skips_a_comment_quoting_the_root_tag_after_the_declaration():
200+
declaration, _, root = VALID_CHOICE_ITEM.partition("?>")
201+
comment = '<!-- <qti-assessment-item foo="1"> -->'
202+
result = set_qti_item_language(f"{declaration}?>{comment}{root}", "fr")
203+
assert comment in result
204+
assert result.count('xml:lang="fr"') == 1
205+
assert 'xml:lang="en-US"' not in result
206+
validation = validate_qti_item(result)
207+
assert validation.is_valid, validation.errors
208+
209+
210+
def test_set_language_skips_a_comment_quoting_the_root_tag_with_its_own_language():
211+
comment = '<!-- <qti-assessment-item xml:lang="de"> -->\n'
212+
result = set_qti_item_language(comment + ITEM_WITHOUT_LANGUAGE, "es")
213+
assert result == comment + ITEM_WITH_ES
214+
215+
216+
@pytest.mark.parametrize(
217+
"prolog",
218+
[
219+
'<!--\n <qti-assessment-item a="1">\n > still a comment\n-->\n',
220+
'<?note <qti-assessment-item a="1"> ?>\n',
221+
"<!DOCTYPE x [<!ENTITY e \"<qti-assessment-item a='1'>\">]>\n",
222+
'<!DOCTYPE x SYSTEM "a>b">\n',
223+
"<!DOCTYPE x SYSTEM 'a[b'>\n",
224+
"<!DOCTYPE x [\n<!-- don't -->\n]>\n",
225+
"<!DOCTYPE x [\n<!-- a ] b -->\n]>\n",
226+
"<!DOCTYPE x [\n<?p it's ?>\n]>\n",
227+
],
228+
)
229+
def test_set_language_skips_other_prolog_markup_quoting_the_root_tag(prolog):
230+
result = set_qti_item_language(prolog + ITEM_WITHOUT_LANGUAGE, "es")
231+
assert result == prolog + ITEM_WITH_ES
232+
233+
234+
@pytest.mark.parametrize(
235+
"prolog",
236+
[
237+
"<!-- <qti-assessment-item a='1'>\n",
238+
"<?note <qti-assessment-item a='1'>\n",
239+
"<!DOCTYPE x [<!ENTITY e \"<qti-assessment-item a='1'>\n",
240+
"<!DOCTYPE x [<!-- <qti-assessment-item a='1'>\n",
241+
],
242+
)
243+
def test_set_language_leaves_an_item_with_unterminated_prolog_markup_unchanged(
244+
prolog,
245+
):
246+
raw_data = prolog + ITEM_WITHOUT_LANGUAGE
247+
assert set_qti_item_language(raw_data, "es") == raw_data
248+
249+
250+
def test_set_language_stamps_an_item_with_a_byte_order_mark():
251+
result = set_qti_item_language("\ufeff" + ITEM_WITHOUT_LANGUAGE, "es")
252+
assert result == "\ufeff" + ITEM_WITH_ES

‎contentcuration/contentcuration/utils/assessment/qti/media.py‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,68 @@
2525
STUDIO_ATTRIBUTE_OR_QUOTED_VALUE_REGEX = re.compile(
2626
r"""(?P<studio>\s+data-studio-[\w.-]+\s*=\s*(?:"[^"]*"|'[^']*'))|"[^"]*"|'[^']*'"""
2727
)
28+
_WHITESPACE_REGEX = re.compile(r"\s*")
29+
_DOCTYPE_PLAIN_REGEX = re.compile(r"[^\"'<>\[\]]*")
2830

2931
QTI_MEDIA_ATTRIBUTE_VALUE_REGEX = re.compile(
3032
r"(?P<attr>" + "|".join(QTI_REFERENCE_ATTRIBUTES + ("srcset",)) + r")"
3133
r'(?P<eq>\s*=\s*)(?P<quote>["\'])(?P<value>[^"\']*)(?P=quote)'
3234
)
3335

3436

37+
def _skip_past(raw_data, terminator, start):
38+
end = raw_data.find(terminator, start)
39+
return None if end == -1 else end + len(terminator)
40+
41+
42+
def _skip_doctype(raw_data, position):
43+
"""Return the offset past a DOCTYPE starting at ``position``, or None if unterminated."""
44+
in_subset = False
45+
position += len("<!DOCTYPE")
46+
while position is not None:
47+
position = _DOCTYPE_PLAIN_REGEX.match(raw_data, position).end()
48+
if position >= len(raw_data):
49+
break
50+
char = raw_data[position]
51+
if char in "\"'":
52+
position = _skip_past(raw_data, char, position + 1)
53+
elif in_subset and raw_data.startswith("<!--", position):
54+
position = _skip_past(raw_data, "-->", position + 4)
55+
elif in_subset and raw_data.startswith("<?", position):
56+
position = _skip_past(raw_data, "?>", position + 2)
57+
elif char == "[":
58+
in_subset = True
59+
position += 1
60+
elif char == "]":
61+
in_subset = False
62+
position += 1
63+
elif char == ">" and not in_subset:
64+
return position + 1
65+
else:
66+
position += 1
67+
return None
68+
69+
70+
def _find_root_start_tag(raw_data):
71+
"""
72+
Locate the item root start tag by stepping over the prolog (comments, processing
73+
instructions, DOCTYPE) that may quote it. Scans with ``str.find`` so memory stays
74+
constant however large the prolog is.
75+
"""
76+
position = 1 if raw_data.startswith("\ufeff") else 0
77+
while position is not None:
78+
position = _WHITESPACE_REGEX.match(raw_data, position).end()
79+
if raw_data.startswith("<!--", position):
80+
position = _skip_past(raw_data, "-->", position + 4)
81+
elif raw_data.startswith("<?", position):
82+
position = _skip_past(raw_data, "?>", position + 2)
83+
elif raw_data.startswith("<!DOCTYPE", position):
84+
position = _skip_doctype(raw_data, position)
85+
else:
86+
return ITEM_ROOT_START_TAG_REGEX.match(raw_data, position)
87+
return None
88+
89+
3590
def get_qti_media_references(raw_data):
3691
"""
3792
Scan QTI item XML for <checksum>.<ext> media references in src/href/data/srcset
@@ -115,7 +170,10 @@ def _replace_root(match):
115170
return tag[: attribute.start()] + replacement + tag[attribute.end() :]
116171
return f"{tag[:-1].rstrip()}{replacement}>"
117172

118-
return ITEM_ROOT_START_TAG_REGEX.sub(_replace_root, raw_data, count=1)
173+
match = _find_root_start_tag(raw_data)
174+
if match is None:
175+
return raw_data
176+
return raw_data[: match.start()] + _replace_root(match) + raw_data[match.end() :]
119177

120178

121179
def strip_studio_attributes(raw_data):

0 commit comments

Comments
 (0)