diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 17ab06c358..1fc5d61340 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -30,33 +30,6 @@ /> -
after
text
a b and c
a' + + 'b
see the docs
under' + + 'struck
It is not four.
It is not' + " four.
It is wrong' + ' under here.
It is a' + ' b here.
It is a b here.
It is wrong It is wrong onetwo", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_style_is_filtered_to_the_allowed_properties(self): + # Kolibri renders an item through SafeHTML, which keeps only these + # properties, so a declaration it would drop is dropped here instead of + # travelling as far as the learner. + result = self._convert( + 'big' + ) + + self.assertIn('big', result.xml) + self.assertNotIn("font-size", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_style_property_names_are_normalized_to_lowercase(self): + # CSS property names are case-insensitive, so an authored declaration may + # arrive in any casing. It leaves in one, because the reverse conversion and + # Kolibri's own allowlist both match a property by name. + result = self._convert('big') + + self.assertIn('big', result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_style_is_dropped_when_no_property_is_allowed(self): + result = self._convert('big') + + self.assertIn("big", result.xml) + self.assertNotIn("style", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_a_decorated_tag_keeps_its_own_allowed_style(self): + result = self._convert('It is not four.') + + self.assertIn( + 'not', + result.xml, + ) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_a_decorated_tag_carrying_its_own_decoration_combines_the_two(self): + # Two text-decoration declarations do not stack in CSS -- the last one wins, + # so the tag's own decoration would be lost. One declaration holds both + # keywords, which is also what the reverse conversion reads back. + result = self._convert( + 'It is not it.' + ) + + self.assertIn( + 'not', + result.xml, + ) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_raw_html_highlight_is_stripped(self): + # is the one inline mark left with nothing to express it: the QTI + # 3.0 HTML profile has no element for it, and no single declaration says + # "highlighted" the way a text-decoration says underlined. + result = self._convert("It is not four.") + + self.assertIn("It is not four.", result.xml) + self.assertNotIn("", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + def test_maths_in_a_list_item(self): # Validity is not asserted here or below: rendered MathML carries no # namespace, the same gap test_free_response_with_maths lives with. diff --git a/contentcuration/contentcuration/tests/utils/qti/test_html.py b/contentcuration/contentcuration/tests/utils/qti/test_html.py index dc5d162bc7..c9bcbf4141 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_html.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_html.py @@ -608,12 +608,14 @@ def test_attribute_type_conversion(self): self.assertEqual(div.class_, "test-class") self.assertEqual(div.id_, "test-id") + # The source is indented, and whitespace between two inline elements is a word + # gap rather than markup, so it is carried as a TextNode between the two. + a, img = (child for child in div.children if not isinstance(child, TextNode)) + # Test link attributes - a = div.children[0] self.assertEqual(a.href, "file.html?query=test") # Test image attributes - img = div.children[1] self.assertEqual(img.src, "image.png") self.assertEqual(img.alt, "Alt text") self.assertEqual(img.width, 100) diff --git a/contentcuration/contentcuration/tests/utils/qti/test_html_to_markdown.py b/contentcuration/contentcuration/tests/utils/qti/test_html_to_markdown.py index bd2e4002e2..bdf37e3aaf 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_html_to_markdown.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_html_to_markdown.py @@ -59,10 +59,68 @@ def test_headings(): def test_inline_emphasis_styles(): assert _markdown_from_html("b") == "**b**" assert _markdown_from_html("i") == "*i*" - assert _markdown_from_html("gone") == "~~gone~~" + assert ( + _markdown_from_html( + 'gone' + ) + == "~~gone~~" + ) assert _markdown_from_html("x = 1") == "`x = 1`" +def test_raw_decoration_tags_are_read_as_their_mark(): + # The forward conversion writes a decorated span, but a foreign QTI package is + # free to send the tag itself, so both spellings are read. + for tag in ("s", "del", "strike"): + assert _markdown_from_html(f"<{tag}>gone{tag}>") == "~~gone~~" + + +def test_underlined_span_becomes_a_perseus_underline(): + # ``gfm-like`` has no underline, but Perseus simple-markdown reads __text__ as + # one, and the derived exercise is rendered by it — so the decoration survives. + assert ( + _markdown_from_html('a') + == "__a__" + ) + + +def test_span_carrying_both_decorations_keeps_both(): + assert ( + _markdown_from_html( + 'a' + ) + == "~~__a__~~" + ) + + +def test_a_decoration_is_read_whatever_its_casing(): + # A foreign QTI package is under no obligation to write its CSS in lowercase, + # and CSS does not require it to: both the property and the keyword are + # case-insensitive. + assert ( + _markdown_from_html( + 'a' + ) + == "~~a~~" + ) + assert ( + _markdown_from_html('a') + == "__a__" + ) + + +def test_span_without_a_markdown_equivalent_unwraps(): + # Neither flavour can express a colour, so the text survives and the style does + # not — the same as any other span the forward conversion never writes. + assert _markdown_from_html('a') == "a" + + +def test_alignment_is_dropped(): + # Perseus markdown cannot express an alignment either, and a block renderer + # reads an element's content, never its attributes. + assert _markdown_from_html('a') == "a" + + def test_link(): result = _markdown_from_html('text') assert result == "[text](https://example.com)" @@ -109,8 +167,9 @@ def test_table(): "# Heading level 1", "## Heading level 2", ( - "A paragraph with **bold**, *italic*, ~~strikethrough~~, `inline code`, " - "a [link](https://example.com), and math $$x^2 + y^2$$ inline." + "A paragraph with **bold**, *italic*, ~~strikethrough~~, __underline__, " + "`inline code`, a [link](https://example.com), and math $$x^2 + y^2$$ " + "inline." ), "".format(exercises.CONTENT_STORAGE_FORMAT.format("abc123.png")), "> A blockquote paragraph.", diff --git a/contentcuration/contentcuration/tests/utils/qti/test_validation.py b/contentcuration/contentcuration/tests/utils/qti/test_validation.py index affca9d82a..02cd2abc06 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_validation.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_validation.py @@ -305,6 +305,34 @@ def test_rejects_catalog_before_item_body(self): self.assertIn("qti-item-body", result.errors[0].message) +STYLED_ITEM = _item_xml( + "item_styled", + "Styled Item", + '' + "choice_0" + "", + '' + "" + 'Which is not' + ' wrong?' + "" + 'Option A' + "", +) + + +class StyledItemTests(unittest.TestCase): + """The QTI 3.0 HTML profile has no , no and no alignment attribute, so the + editor writes all three as a style. QTI declares no style attribute either, but its + element definitions carry a lax attribute wildcard that admits one.""" + + def test_accepts_style_attribute(self): + result = validate_qti_item(STYLED_ITEM) + self.assertTrue(result.is_valid) + self.assertEqual(result.errors, []) + + class SchemaReuseTests(unittest.TestCase): def test_schema_compiled_once_across_multiple_validate_calls(self): _compiled_schema.cache_clear() diff --git a/contentcuration/contentcuration/tests/utils/test_markdown.py b/contentcuration/contentcuration/tests/utils/test_markdown.py index 655f44dbf5..da234df9e7 100644 --- a/contentcuration/contentcuration/tests/utils/test_markdown.py +++ b/contentcuration/contentcuration/tests/utils/test_markdown.py @@ -339,3 +339,61 @@ def test_sized_image_survives_the_model_layer(self): self.assertEqual(img.src, "cs.png") self.assertEqual(img.alt, "a") self.assertEqual((img.width, img.height), (230, 287)) + + +class StrikethroughTests(unittest.TestCase): + """The QTI 3.0 HTML profile has no , so ~~…~~ renders as a decorated span.""" + + def test_strikethrough_renders_as_a_decorated_span(self): + self.assertEqual( + render_markdown("It is ~~not~~ four."), + 'It is not' + " four.\n", + ) + + def test_strikethrough_keeps_its_inner_markup(self): + self.assertEqual( + render_markdown("~~**gone**~~"), + '' + "gone\n", + ) + + def test_strikethrough_survives_the_model_layer(self): + # The converter builds its item body by parsing this HTML into the QTI + # models, which reject an attribute they do not declare. + paragraph = ElementTreeBase.from_string(render_markdown("~~gone~~"))[0] + span = paragraph.children[0] + self.assertEqual(span.style, "text-decoration: line-through;") + + +class UnderlineTests(unittest.TestCase): + """Perseus simple-markdown reads __…__ as an underline, not as strong. + + Studio's editor writes the underline mark that way, and Kolibri renders the + legacy exercise through Perseus, so that is the grammar the stored markdown + follows. The QTI 3.0 HTML profile has no , so it renders as a decorated span. + """ + + def test_underline_renders_as_a_decorated_span(self): + self.assertEqual( + render_markdown("It is __not__ four."), + 'It is not four.\n', + ) + + def test_double_asterisk_is_still_strong(self): + self.assertEqual( + render_markdown("It is **very** four."), + "It is very four.\n", + ) + + def test_underline_keeps_its_inner_markup(self): + self.assertEqual( + render_markdown("__**both**__"), + '' + "both\n", + ) + + def test_underline_survives_the_model_layer(self): + paragraph = ElementTreeBase.from_string(render_markdown("__under__"))[0] + span = paragraph.children[0] + self.assertEqual(span.style, "text-decoration: underline;") diff --git a/contentcuration/contentcuration/utils/assessment/markdown.py b/contentcuration/contentcuration/utils/assessment/markdown.py index f11dcf1e07..d14a12cd8f 100644 --- a/contentcuration/contentcuration/utils/assessment/markdown.py +++ b/contentcuration/contentcuration/utils/assessment/markdown.py @@ -14,6 +14,12 @@ from contentcuration.utils.assessment.qti.mathml.core import Semantics +# The QTI 3.0 HTML profile has no or , so a decoration is carried as a style +# on a . Shared with the reverse conversion in qti/html_to_markdown.py and with +# the raw-HTML rewrite in qti/convert.py. +STRIKETHROUGH_DECORATION = "line-through" +UNDERLINE_DECORATION = "underline" + # Regex patterns for $$ delimited math INLINE_PATTERN = re.compile(r"^\$\$([\s\S]+?)\$\$") BLOCK_PATTERN = re.compile(r"^\$\$([\s\S]+?)\$\$", re.M) @@ -174,6 +180,86 @@ def texmath_to_mathml_plugin(md: MarkdownIt) -> None: md.add_render_rule("math_block", render_math_block) +def render_strikethrough_open( + self: RendererProtocol, + tokens: list[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + return f'' + + +def render_strikethrough_close( + self: RendererProtocol, + tokens: list[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + return "" + + +def strikethrough_as_style_plugin(md: MarkdownIt) -> None: + """Render ``~~…~~`` as a decorated span rather than the default ````. + + The QTI 3.0 HTML profile has no ````, so an item carrying one is rejected + by the item schema. It does have ````, and the schema admits a ``style`` + attribute through its lax wildcard, so the decoration travels as a style. + + ``underline_as_style_plugin`` is its counterpart for ``__…__``. An author's raw + ```` or ```` is neither: markdown-it passes raw HTML through as an opaque + chunk with no token to hang a render rule on, so those are rewritten a layer + later, in ``qti/convert.py``. + """ + md.add_render_rule("s_open", render_strikethrough_open) + md.add_render_rule("s_close", render_strikethrough_close) + + +# CommonMark spells strong two ways, and markdown-it records which one it parsed on +# the token's markup. Perseus splits them: ``**`` is strong, ``__`` is an underline. +UNDERLINE_MARKUP = "__" + + +def render_strong_open( + self: RendererProtocol, + tokens: list[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + if tokens[idx].markup == UNDERLINE_MARKUP: + return f'' + return self.renderToken(tokens, idx, options, env) + + +def render_strong_close( + self: RendererProtocol, + tokens: list[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + if tokens[idx].markup == UNDERLINE_MARKUP: + return "" + return self.renderToken(tokens, idx, options, env) + + +def underline_as_style_plugin(md: MarkdownIt) -> None: + """Render ``__…__`` as an underlined span rather than the default ````. + + CommonMark reads ``__x__`` as strong, but Perseus simple-markdown — which + renders every legacy Studio exercise in Kolibri — reads it as ````, and the + editor writes its underline mark that way to match. Reading it as strong here + would turn an author's underline into bold on the way into a QTI item. + + ``**x**`` keeps its ````: the two spellings are distinguished by the + token's ``markup``, so only the underscore form is diverted. + """ + md.add_render_rule("strong_open", render_strong_open) + md.add_render_rule("strong_close", render_strong_close) + + def sized_image_plugin(md: MarkdownIt) -> None: """Plugin for Perseus images carrying a size and/or alignment suffix. @@ -188,6 +274,8 @@ def sized_image_plugin(md: MarkdownIt) -> None: .disable("linkify") .use(texmath_to_mathml_plugin) .use(sized_image_plugin) + .use(strikethrough_as_style_plugin) + .use(underline_as_style_plugin) ) diff --git a/contentcuration/contentcuration/utils/assessment/qti/base.py b/contentcuration/contentcuration/utils/assessment/qti/base.py index 5467654a6b..9196fab4c5 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/base.py +++ b/contentcuration/contentcuration/utils/assessment/qti/base.py @@ -2,8 +2,10 @@ import xml.etree.ElementTree as ET from abc import ABC from enum import Enum +from functools import lru_cache from functools import partial from typing import Annotated +from typing import get_args from typing import List from typing import Optional from typing import Set @@ -167,6 +169,10 @@ class BaseSequence(XMLElement): label: Optional[str] = None # We explicitly do not set the base value. dir_: Optional[Dir] = None + # QTI 3.0 declares no style attribute but the item XSD allows it. + # Studio uses it for what the HTML profile has no element for — a text decoration, an + # alignment — rather than shipping a qti-stylesheet and a vocabulary of classes. + style: Optional[str] = None # Pydantic's BaseModel Metaclass is only importable from an internal module, @@ -209,6 +215,21 @@ def get_class_for_tag(cls, tag_name: str) -> Optional[Type]: return getattr(cls, "_registry", {}).get(tag_name) +@lru_cache(maxsize=1) +def _inline_classes() -> tuple: + """The classes the schema lets sit inline, read off ``InlineGroup`` itself. + + Taken from the union rather than restated here, so that a type the schema starts + admitting inline -- MathML being the one that is neither HTML nor an interaction -- + does not also have to be remembered in this module. + """ + # Imported here rather than at module scope: content_types imports this module, so + # the two only resolve once the registry that html populates is in place. + from contentcuration.utils.assessment.qti.html.content_types import InlineGroup + + return tuple(member for member in get_args(InlineGroup) if isinstance(member, type)) + + class ElementTreeBase(BaseSequence, metaclass=RegistryMeta): @classmethod def from_element(cls, element: ET.Element) -> "ElementTreeBase": @@ -254,14 +275,38 @@ def _extract_children( children.append(TextNode(text=element.text)) # Process child elements - for child_elem in element: + child_elements = list(element) + for index, child_elem in enumerate(child_elements): children.append(cls.from_element(child_elem)) # Add tail text after child element - if child_elem.tail and child_elem.tail.strip(): - children.append(TextNode(text=child_elem.tail)) + tail = child_elem.tail + if tail and ( + tail.strip() or cls._separates_inline_siblings(child_elements, index) + ): + children.append(TextNode(text=tail)) return children + @classmethod + def _separates_inline_siblings(cls, child_elements, index: int) -> bool: + """Whether whitespace after ``child_elements[index]`` is content of its own. + + Between two inline elements it is the word gap a reader sees, and dropping it + runs the two words together. Between block elements it is the renderer's own + indentation, which says nothing and does not belong in the item. + """ + if index + 1 >= len(child_elements): + return False + return all( + cls._is_inline_tag(child_elements[offset].tag) + for offset in (index, index + 1) + ) + + @classmethod + def _is_inline_tag(cls, tag: str) -> bool: + target_class = type(cls).get_class_for_tag(tag) + return target_class is not None and issubclass(target_class, _inline_classes()) + @classmethod def from_string(cls, string: str) -> List["ElementTreeBase"]: """Parse markup string and return list of ElementTreeBase instances""" diff --git a/contentcuration/contentcuration/utils/assessment/qti/constants.py b/contentcuration/contentcuration/utils/assessment/qti/constants.py index 99ea507af3..05d530734a 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/constants.py +++ b/contentcuration/contentcuration/utils/assessment/qti/constants.py @@ -67,3 +67,19 @@ class ResourceType(Enum): ASSESSMENT_TEST = "imsqti_test_xmlv3p0" ASSESSMENT_ITEM = "imsqti_item_xmlv3p0" RESPONSE_TEMPLATE = "imsqti_rptemplate_xmlv3p0" + + +# Inline style properties an item may carry. Kolibri's SafeHTML rebuilds every style +# attribute from this same list before rendering an item (its ALLOWED_STYLE_PROPS, in +# kolibri-common/components/SafeHTML/index.js, which the qti_viewer renders through), +# dropping whatever is not on it — so a declaration outside this list never reaches the +# learner. Hand-synced with that list and with the ricecooker/le_utils KPUB ingest +# allowlist (learningequality/ricecooker#685); no shared package carries it yet. +ALLOWED_STYLE_PROPERTIES = frozenset( + { + "text-align", + "color", + "background-color", + "text-decoration", + } +) diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index 9d9473c1c7..569a17df7e 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -12,6 +12,8 @@ from lxml import etree from contentcuration.utils.assessment.markdown import render_markdown +from contentcuration.utils.assessment.markdown import STRIKETHROUGH_DECORATION +from contentcuration.utils.assessment.markdown import UNDERLINE_DECORATION from contentcuration.utils.assessment.qti.assessment_item import AssessmentItem from contentcuration.utils.assessment.qti.assessment_item import BaseValue from contentcuration.utils.assessment.qti.assessment_item import CorrectResponse @@ -31,6 +33,7 @@ from contentcuration.utils.assessment.qti.catalog import Catalog from contentcuration.utils.assessment.qti.catalog import CatalogInfo from contentcuration.utils.assessment.qti.catalog import HtmlContent +from contentcuration.utils.assessment.qti.constants import ALLOWED_STYLE_PROPERTIES from contentcuration.utils.assessment.qti.constants import BaseType from contentcuration.utils.assessment.qti.constants import Cardinality from contentcuration.utils.assessment.qti.constants import Orientation @@ -86,16 +89,84 @@ class QTIConversionResult: file_dependencies: List[str] -def _strip_unsupported_markup(markup: str) -> str: +# Tags the QTI 3.0 HTML profile has no element for, but whose rendering is a text +# decoration a can carry as a style. `render_markdown` writes the +# strikethrough it parses as such a span already; these are the ones an author typed +# as raw HTML, which the renderer passes through untouched. +_DECORATION_TAGS = { + "s": STRIKETHROUGH_DECORATION, + "del": STRIKETHROUGH_DECORATION, + "strike": STRIKETHROUGH_DECORATION, + "u": UNDERLINE_DECORATION, + "ins": UNDERLINE_DECORATION, +} + +# Tags with nothing left to express them: the profile has no element, and no single +# style declaration says what they mean. The text survives, the tag does not. An +# anchor has nothing to navigate to on a device with no internet access. +_UNWRAPPED_TAGS = ("a", "mark") + + +def _filter_style(element) -> None: + """Reduce an element's style to the declarations Kolibri will render. + + The property name is lowercased on the way out. CSS does not care, but the + reverse conversion in ``html_to_markdown`` and Kolibri's own allowlist both + match a property by name, so an authored ``TEXT-DECORATION`` leaves here in the + one spelling everything downstream looks for. The value is passed through + untouched: nothing reads it by name, and normalizing it would edit content. + """ + kept = "; ".join( + f"{prop.strip().lower()}:{value}" + for prop, sep, value in ( + declaration.partition(":") + for declaration in element.get("style", "").split(";") + ) + if sep and prop.strip().lower() in ALLOWED_STYLE_PROPERTIES + ) + if kept: + element.set("style", f"{kept};") + else: + element.attrib.pop("style", None) + + +def _add_decoration(element, decoration: str) -> None: + """Add a text decoration to an element, keeping any the style already sets. + + Merged into one declaration rather than appended as a second: two + ``text-decoration`` declarations do not stack in CSS, the last one simply wins, + so a tag carrying its own decoration would lose it. One declaration holding both + keywords is what a browser renders and what ``_text_decorations`` reads back. + """ + kept = [] + decorations = [decoration] + for declaration in element.get("style", "").split(";"): + prop, sep, value = declaration.partition(":") + if not sep: + continue + if prop.strip() == "text-decoration": + decorations = value.split() + decorations + else: + kept.append(declaration.strip()) + kept.append("text-decoration: {}".format(" ".join(dict.fromkeys(decorations)))) + element.set("style", "; ".join(kept) + ";") + + +def _adapt_unsupported_markup(markup: str) -> str: """ - Unwrap every tag a QTI item body cannot carry, keeping its content. + Rewrite every tag a QTI item body cannot carry, keeping its content. - An anchor has nothing to navigate to on a device with no internet access. - The QTI 3.0 HTML profile has no element for the inline marks. - Runs on the rendered markup, so tags typed as raw HTML are stripped too. + Runs on the rendered markup, so tags typed as raw HTML are adapted too — an + author's own style attribute included, which is filtered rather than refused. """ root = etree.fromstring(f"{markup}") - etree.strip_tags(root, "a", "s", "del", "ins", "u", "mark", "strike") + for element in list(root.iter("*")): + if element.get("style") is not None: + _filter_style(element) + for element in list(root.iter(*_DECORATION_TAGS)): + _add_decoration(element, _DECORATION_TAGS[element.tag]) + element.tag = "span" + etree.strip_tags(root, *_UNWRAPPED_TAGS) return (root.text or "") + "".join( etree.tostring(child, encoding="unicode") for child in root ) @@ -105,7 +176,7 @@ def _create_html_content_from_text(text: str) -> FlowContentList: """Convert text content to QTI HTML flow content.""" if not text.strip(): return [] - markup = _strip_unsupported_markup(render_markdown(text)) + markup = _adapt_unsupported_markup(render_markdown(text)) return ElementTreeBase.from_string(markup) diff --git a/contentcuration/contentcuration/utils/assessment/qti/html_to_markdown.py b/contentcuration/contentcuration/utils/assessment/qti/html_to_markdown.py index 32ec68dd91..388da67537 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/html_to_markdown.py +++ b/contentcuration/contentcuration/utils/assessment/qti/html_to_markdown.py @@ -11,6 +11,11 @@ ``.`` ``src``, which is re-prefixed with the Perseus content-storage placeholder here (see ``test_html_to_markdown`` for the round-trip coverage). +Both decorations round-trip through their span: ``~~…~~`` and ``__…__``, the +latter written by ``underline_as_style_plugin`` and read back here, since Perseus +simple-markdown — which renders the derived exercise — treats ``__text__`` as +````. + This module has no knowledge of QTI items — it operates on a sequence of sibling lxml elements. """ @@ -20,6 +25,9 @@ from le_utils.constants import exercises from lxml import etree +from contentcuration.utils.assessment.markdown import STRIKETHROUGH_DECORATION +from contentcuration.utils.assessment.markdown import UNDERLINE_DECORATION + logger = logging.getLogger(__name__) CONTENT_STORAGE_PREFIX = exercises.CONTENT_STORAGE_FORMAT.format("") @@ -70,6 +78,38 @@ def _render_img(el): return "".format(el.get("alt", ""), CONTENT_STORAGE_PREFIX, src) +def _text_decorations(el): + """The decoration keywords an element's style sets, whichever property carries them. + + Matched case-insensitively. A span written by ``convert.py`` is already + lowercased, but a foreign QTI package is under no obligation to be, and CSS + does not require it: both the property and the keyword are case-insensitive. + """ + decorations = set() + for declaration in el.get("style", "").split(";"): + prop, _, values = declaration.partition(":") + if prop.strip().lower().startswith("text-decoration"): + decorations.update(value.lower() for value in values.split()) + return decorations + + +def _render_span(el): + """A decorated span is how a strikethrough and an underline are carried. + + ``render_markdown`` writes the first; the editor writes either, since the QTI + HTML profile has no ```` and no ````. Any other span unwraps, keeping its + content: whatever a foreign QTI package styles a span with has nothing in + Perseus markdown to say it. + """ + decorations = _text_decorations(el) + rendered = _render_inline(el) + if UNDERLINE_DECORATION in decorations: + rendered = "__{}__".format(rendered) + if STRIKETHROUGH_DECORATION in decorations: + rendered = "~~{}~~".format(rendered) + return rendered + + def _render_heading(el): level = int(_localname(el)[1]) return "{} {}\n\n".format("#" * level, _render_inline(el).strip()) @@ -176,6 +216,7 @@ def _render_table(el): "ul": _render_list, "ol": _render_list, "table": _render_table, + "span": _render_span, "p": lambda el: "{}\n\n".format(_render_inline(el)), "div": lambda el: "{}\n\n".format(_render_inline(el)), **{"h{}".format(level): _render_heading for level in range(1, 7)}, diff --git a/package.json b/package.json index e7b7e93354..dd0e1d618b 100644 --- a/package.json +++ b/package.json @@ -56,9 +56,11 @@ "@tiptap/core": "^3.29.0", "@tiptap/extension-code-block-lowlight": "^3.29.0", "@tiptap/extension-link": "^3.29.0", + "@tiptap/extension-strike": "^3.29.0", "@tiptap/extension-subscript": "^3.29.0", "@tiptap/extension-superscript": "^3.29.0", "@tiptap/extension-text-align": "^3.29.0", + "@tiptap/extension-underline": "^3.29.0", "@tiptap/starter-kit": "^3.29.0", "@tiptap/vue-2": "^3.29.0", "ajv": "^8.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 655ebc6292..e9d53b17ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@tiptap/extension-link': specifier: ^3.29.0 version: 3.29.0(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0) + '@tiptap/extension-strike': + specifier: ^3.29.0 + version: 3.29.0(@tiptap/core@3.29.0(@tiptap/pm@3.29.0)) '@tiptap/extension-subscript': specifier: ^3.29.0 version: 3.29.0(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0) @@ -29,6 +32,9 @@ importers: '@tiptap/extension-text-align': specifier: ^3.29.0 version: 3.29.0(@tiptap/core@3.29.0(@tiptap/pm@3.29.0)) + '@tiptap/extension-underline': + specifier: ^3.29.0 + version: 3.29.0(@tiptap/core@3.29.0(@tiptap/pm@3.29.0)) '@tiptap/starter-kit': specifier: ^3.29.0 version: 3.29.0
big
b
i
gone
x = 1
<{tag}>gone{tag}>
a
Which is not' + ' wrong?
' + "gone
It is very four.
' + "both