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 @@ /> -
- -
- - -
({ 'insert-image': target => imageHandler.openCreateModal({ targetElement: target }), - 'insert-link': () => linkHandler.openLinkEditor(), 'insert-math': target => mathHandler.openCreateMathModal({ targetElement: target }), })); @@ -318,12 +282,9 @@ editorContainer, imageUploadModal, formulasMenu, - isReady, hasFocusWithin, handleFocusout, handleDrop, - linkHandler, - editor, mathHandler, isTouchDevice, imageHandler, @@ -446,7 +407,6 @@ outline-color: #007bff; } - .link-editor-popover-wrapper, .image-upload-popover-wrapper, .math-modal-popover-wrapper { position: fixed; @@ -461,7 +421,6 @@ pointer-events: none; } - .link-editor-popover-wrapper > *, .image-upload-popover-wrapper > *, .math-modal-popover-wrapper > * { pointer-events: auto; diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue index cdf11dae8f..04275bb229 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -193,7 +193,6 @@ canClearFormat, historyActions, alignAction, - alignActionHidden, textActions, listActions, insertTools, @@ -267,8 +266,6 @@ { name: 'align', groupActions: [alignAction.value], - // Reason on the flag, in useToolbarActions.js. - hide: alignActionHidden, }, { name: 'clearFormat', @@ -349,9 +346,6 @@ const toolbarGroupsWithDividers = computed(() => { const groups = []; toolbarGroups.value.forEach((group, index) => { - if (group.hide) { - return; - } groups.push(group); if (index < toolbarGroups.value.length - 1) { groups.push({ type: 'divider' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue index 13607a4e40..1c0e301e09 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue @@ -88,16 +88,13 @@ :is-active="action.isActive" @click="action.handler" /> - - + + or , so the item schema rejects an - // item carrying either and the save fails. Dropping the marks rather than only - // their toolbar buttons also takes away the keyboard shortcuts and the paste - // path, which would otherwise still produce content that cannot be saved. + // A link has nothing to navigate to on a device with no internet access, so + // the editor offers none and the legacy conversion unwraps the ones it finds + // (utils/assessment/qti/convert.py). Dropping the mark rather than only the + // toolbar button is what keeps a pasted anchor from arriving as one. + link: false, + // Replaced by the versions in extensions/TextDecoration.js, which write the + // decoration as a style on a — the QTI 3.0 HTML profile has no or . strike: false, underline: false, }), CodeBlockSyntaxHighlight, Small, + StyledStrike, + StyledUnderline, Superscript, Subscript, Image, - CustomLink, // Use our custom Link extension Math, TextAlign.configure({ types: ['heading', 'paragraph', 'image', 'small'], diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js index 2d3ab693c1..ed5135f136 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js @@ -31,23 +31,11 @@ export function useToolbarActions(emit) { /** * Drop the actions marked `hide`, which every toolbar honours — the desktop one and - * the mobile bars alike. Same convention as the hidden groups in EditorToolbar.vue: - * the action stays defined, with the reason it is not offered, so restoring it is a - * matter of deleting one flag. + * the mobile bars alike. The action stays defined, with the reason it is not offered, + * so restoring it is a matter of deleting one flag. */ const visible = actions => actions.filter(action => !action.hide); - /* - * TextAlign writes `style="text-align: …"`, and the QTI 3.0 HTML profile declares no - * style attribute — the item schema admits one only through its lax wildcard, so an - * aligned paragraph saves and then ships as non-conformant QTI. The image extension - * carries alignment as `data-text-align` instead, which the schema does allow. - * - * Kept out of `alignAction` so a toolbar can ask whether to offer the control without - * evaluating the action, which reads the editor's current selection to pick its icon. - */ - const alignActionHidden = true; - // helper const getEffectiveAlignment = editorInstance => { if (!editorInstance) return 'left'; @@ -388,44 +376,36 @@ export function useToolbarActions(emit) { }, ]); - const textActions = computed(() => - visible([ - { - name: 'bold', - title: bold$(), - icon: require('../../assets/icon-bold.svg'), - handler: handleBold, - isActive: isMarkActive('bold'), - }, - { - name: 'italic', - title: italic$(), - icon: require('../../assets/icon-italic.svg'), - handler: handleItalic, - isActive: isMarkActive('italic'), - }, - { - name: 'underline', - title: underline$(), - icon: require('../../assets/icon-underline.svg'), - handler: handleUnderline, - isActive: isMarkActive('underline'), - // The QTI 3.0 HTML profile has no or , so the item schema rejects an item - // carrying either and the save fails. Both marks are switched off in useEditor.js - // as well, since hiding a button leaves its keyboard shortcut behind — offering - // these again means undoing both halves. - hide: true, - }, - { - name: 'strikethrough', - title: strikethrough$(), - icon: require('../../assets/icon-strikethrough.svg'), - handler: handleStrikethrough, - isActive: isMarkActive('strike'), - hide: true, - }, - ]), - ); + const textActions = computed(() => [ + { + name: 'bold', + title: bold$(), + icon: require('../../assets/icon-bold.svg'), + handler: handleBold, + isActive: isMarkActive('bold'), + }, + { + name: 'italic', + title: italic$(), + icon: require('../../assets/icon-italic.svg'), + handler: handleItalic, + isActive: isMarkActive('italic'), + }, + { + name: 'underline', + title: underline$(), + icon: require('../../assets/icon-underline.svg'), + handler: handleUnderline, + isActive: isMarkActive('underline'), + }, + { + name: 'strikethrough', + title: strikethrough$(), + icon: require('../../assets/icon-strikethrough.svg'), + handler: handleStrikethrough, + isActive: isMarkActive('strike'), + }, + ]); const listActions = computed(() => [ { @@ -564,7 +544,6 @@ export function useToolbarActions(emit) { historyActions, textActions, alignAction, - alignActionHidden, listActions, scriptActions, insertTools, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/TextDecoration.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/TextDecoration.js new file mode 100644 index 0000000000..27d3a1004f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/TextDecoration.js @@ -0,0 +1,34 @@ +/* + * Underline and strikethrough, written as a decorated rather than or . + * + * The QTI 3.0 HTML profile has neither element, 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 instead — the same way + * TextAlign already carries alignment. + * + * Only the output changes. Upstream's parse rules already read `text-decoration` off a + * style attribute, so a span written here comes back as the mark that wrote it, and a + * pasted or still arrives as one. + */ +import { mergeAttributes } from '@tiptap/core'; +import { Strike } from '@tiptap/extension-strike'; +import { Underline } from '@tiptap/extension-underline'; + +const renderAsDecoratedSpan = decoration => + function renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + style: `text-decoration: ${decoration}`, + }), + 0, + ]; + }; + +export const StyledStrike = Strike.extend({ + renderHTML: renderAsDecoratedSpan('line-through'), +}); + +export const StyledUnderline = Underline.extend({ + renderHTML: renderAsDecoratedSpan('underline'), +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js index c554a1e16e..02a6314137 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js @@ -6,7 +6,7 @@ import EditorToolbar from '../TipTapEditor/components/EditorToolbar.vue'; import { getTipTapEditorStrings } from '../TipTapEditor/TipTapEditorStrings'; import { tabIn } from 'shared/utils/testing'; -const { textFormatOptions$ } = getTipTapEditorStrings(); +const { textFormatOptions$, alignRight$ } = getTipTapEditorStrings(); // Every editor read the toolbar makes while rendering: undo/redo availability, // mark state, the alignment probe in `getEffectiveAlignment`, and the @@ -226,3 +226,13 @@ describe('EditorToolbar contributed insert actions', () => { }); }); }); + +describe('EditorToolbar alignment control', () => { + // The action itself was always defined; what changed is that the toolbar no longer + // hides it, so the assertion has to be on what renders. + it('renders the alignment control', async () => { + await renderToolbar(); + + expect(screen.getByRole('button', { name: alignRight$() })).toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileFormattingBar.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileFormattingBar.spec.js index 4f29d7304c..047089a6c7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileFormattingBar.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileFormattingBar.spec.js @@ -15,17 +15,21 @@ jest.mock('shared/utils/browserInfo.js', () => { return jest.requireActual('shared/utils/browserInfo.js'); }); -const { decreaseFormatSize$, textFormattingToolbar$ } = getTipTapEditorStrings(); +const { decreaseFormatSize$, textFormattingToolbar$, alignRight$ } = getTipTapEditorStrings(); const formattingBar = () => screen.queryByRole('toolbar', { name: textFormattingToolbar$() }); -// Every editor read the bar makes: the format level in `useFormatControls`, and -// the selection the mount hook scrolls into view. +// Every editor read the bar makes: the format level in `useFormatControls`, the +// selection the mount hook scrolls into view, and the node under the cursor that the +// alignment control reads to pick its icon. function makeEditorStub({ smallText = false } = {}) { return { isActive: name => smallText && name === 'small', state: { selection: { from: 0, to: 0 } }, - view: { dom: document.createElement('div') }, + view: { + dom: document.createElement('div'), + domAtPos: () => ({ node: document.createElement('div') }), + }, }; } @@ -185,3 +189,15 @@ describe('MobileFormattingBar keyboard reachability', () => { expect(formattingBar()).not.toBeInTheDocument(); }); }); + +describe('MobileFormattingBar alignment control', () => { + it('renders the alignment control', async () => { + render(MobileFormattingBar, { + provide: { editor: ref(makeEditorStub()) }, + router: new VueRouter(), + }); + await nextTick(); + + expect(screen.getByRole('button', { name: alignRight$() })).toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js index 143dcf05c5..8df3bae839 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/vue'; +import { render, screen, waitFor } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; @@ -94,23 +94,19 @@ describe('TipTapEditor — getContent() logic', () => { describe('TipTapEditor — minimizing from the toolbar', () => { const renderEditor = async listeners => { - let editor; + const ready = jest.fn(); render( { components: { TipTapEditor }, template: '', - mounted() { - editor = this.$children[0]; - }, }, - { listeners, routes: new VueRouter() }, + { listeners: { ...listeners, ready }, routes: new VueRouter() }, ); - // The toolbar treats its buttons as unavailable until the editor reports ready, - // which it does a tick after it is constructed. - await new Promise(resolve => setTimeout(resolve, 0)); + // The toolbar treats its buttons as unavailable until the editor reports ready. + await waitFor(() => expect(ready).toHaveBeenCalled()); await nextTick(); - return editor; + return ready.mock.calls[0][0]; }; const minimize = user => user.click(screen.getByRole('button', { name: 'Minimize Toolbar' })); @@ -120,7 +116,7 @@ describe('TipTapEditor — minimizing from the toolbar', () => { const update = jest.fn(); const onMinimize = jest.fn(); const editor = await renderEditor({ update, minimize: onMinimize }); - editor.editor.commands.setContent('

after

'); + editor.commands.setContent('

after

'); await minimize(user); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useEditor.spec.js index 41b6f5332d..1a31a4645c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useEditor.spec.js @@ -1,12 +1,12 @@ import { Node } from '@tiptap/core'; import { useEditor } from '../TipTapEditor/composables/useEditor'; +import { transformPastedHTML } from '../TipTapEditor/utils/pasteTransform'; import { stubProseMirrorLayout } from 'shared/utils/testing'; /** - * The QTI 3.0 HTML profile has no or , so the item schema rejects an item that - * carries either and the save fails. Removing the toolbar buttons is not enough on its - * own — the marks have to leave the schema, or a keyboard shortcut or a paste still - * produces content that cannot be saved. + * The QTI 3.0 HTML profile has no or , so an item carrying either is rejected by + * the item schema. It does have , and the schema admits a `style` attribute, so + * both decorations are written as a styled span instead of their own element. */ describe('the editor schema', () => { const createEditor = content => { @@ -15,23 +15,109 @@ describe('the editor schema', () => { return editor.value; }; - it('carries no mark for the inline formatting a QTI item cannot hold', () => { - const marks = Object.keys(createEditor('

text

').schema.marks); - expect(marks).not.toContain('underline'); - expect(marks).not.toContain('strike'); - }); + // Read back through the DOM rather than matched as a string: the style attribute is + // serialized by the browser, which is free to normalize the declaration it is given. + const spansIn = html => { + const container = document.createElement('div'); + container.innerHTML = html; + return Array.from(container.querySelectorAll('span')).map(span => ({ + text: span.textContent, + style: span.getAttribute('style'), + })); + }; it('keeps the marks a QTI item can hold', () => { const marks = Object.keys(createEditor('

text

').schema.marks); - expect(marks).toContain('bold'); - expect(marks).toContain('italic'); + expect(marks).toEqual(expect.arrayContaining(['bold', 'italic', 'underline', 'strike'])); }); - it('unwraps underline and strikethrough it is handed, keeping the text', () => { - const editor = createEditor('

a b and c

'); + it('writes underline as a decorated span, not a ', () => { + const editor = createEditor('

text

'); + editor.commands.selectAll(); + editor.commands.toggleUnderline(); + + expect(spansIn(editor.getHTML())).toEqual([ + { text: 'text', style: expect.stringContaining('text-decoration: underline') }, + ]); expect(editor.getHTML()).not.toContain(''); + }); + + it('writes strikethrough as a decorated span, not an ', () => { + const editor = createEditor('

text

'); + editor.commands.selectAll(); + editor.commands.toggleStrike(); + + expect(spansIn(editor.getHTML())).toEqual([ + { text: 'text', style: expect.stringContaining('text-decoration: line-through') }, + ]); expect(editor.getHTML()).not.toContain(''); - expect(editor.getText()).toContain('a b and c'); + }); + + it('reads back the decorated spans it writes', () => { + // A is no node in this schema, so it survives the round trip only by being + // read as the mark that wrote it. + const editor = createEditor( + '

a' + + 'b

', + ); + + expect(spansIn(editor.getHTML())).toEqual([ + { text: 'a', style: expect.stringContaining('text-decoration: underline') }, + { text: 'b', style: expect.stringContaining('text-decoration: line-through') }, + ]); + }); + + it('carries no link mark, so a pasted anchor arrives as its text', () => { + // A link has nothing to navigate to on a device with no internet access, and the + // legacy conversion unwraps the ones it finds. Removing the button is not enough + // on its own — the mark has to leave the schema, or a paste still brings one in. + const editor = createEditor('

see the docs

'); + + expect(Object.keys(editor.schema.marks)).not.toContain('link'); + expect(editor.getHTML()).not.toContain(' or into a decorated span', () => { + const editor = createEditor('

a b and c

'); + + expect(spansIn(editor.getHTML())).toEqual([ + { text: 'b', style: expect.stringContaining('text-decoration: underline') }, + { text: 'c', style: expect.stringContaining('text-decoration: line-through') }, + ]); + }); +}); + +/** + * A pasted decoration is what this schema had no mark for until now: the two were + * switched off in StarterKit, so a or a decorated arrived as plain text. + * Google Docs writes a decoration as a style on a , which is also how the marks + * re-registered here write one, so a pasted run comes back as the mark that publishes it. + */ +describe('a pasted decoration', () => { + const pasteIntoEditor = html => { + const { initializeEditor, editor } = useEditor(); + initializeEditor(transformPastedHTML(html)); + const container = document.createElement('div'); + container.innerHTML = editor.value.getHTML(); + return Array.from(container.querySelectorAll('span')).map(span => ({ + text: span.textContent, + style: span.getAttribute('style'), + })); + }; + + it('reads the decorated spans Google Docs writes back as their marks', () => { + // Docs wraps its clipboard HTML in a , a container + // rather than a bold run, so the decorations inside it are what must survive. + const html = + '' + + '

under' + + 'struck

'; + + expect(pasteIntoEditor(html)).toEqual([ + { text: 'under', style: expect.stringContaining('text-decoration: underline') }, + { text: 'struck', style: expect.stringContaining('text-decoration: line-through') }, + ]); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useToolbarActions.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useToolbarActions.spec.js index 3cb14fbf3b..2e770ce716 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useToolbarActions.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useToolbarActions.spec.js @@ -3,25 +3,21 @@ import { useToolbarActions } from '../TipTapEditor/composables/useToolbarActions const names = actions => actions.value.map(action => action.name); /** - * The actions themselves stay defined and carry a `hide` flag, so what is worth pinning - * is that no toolbar hands them out — the desktop one and both mobile bars read these + * A hidden action stays defined and carries a `hide` flag, so what is worth pinning is + * which actions a toolbar hands out — the desktop one and both mobile bars read these * same arrays. */ describe('the toolbar', () => { - it('offers no underline or strikethrough, which a QTI item cannot carry', () => { - const { textActions } = useToolbarActions(jest.fn()); - expect(names(textActions)).not.toContain('underline'); - expect(names(textActions)).not.toContain('strikethrough'); - }); - it('offers no link, which conversion strips for an offline reader', () => { const { insertTools } = useToolbarActions(jest.fn()); expect(names(insertTools)).not.toContain('link'); }); - it('offers no alignment, which QTI has no attribute to express', () => { - const { alignActionHidden } = useToolbarActions(jest.fn()); - expect(alignActionHidden).toBe(true); + it('offers the inline formatting a QTI item can carry as a style', () => { + const { textActions } = useToolbarActions(jest.fn()); + expect(names(textActions)).toEqual( + expect.arrayContaining(['bold', 'italic', 'underline', 'strikethrough']), + ); }); it('offers subscript and superscript, which QTI does support', () => { @@ -29,9 +25,8 @@ describe('the toolbar', () => { expect(names(scriptActions)).toEqual(expect.arrayContaining(['subscript', 'superscript'])); }); - it('still offers the formatting a QTI item can carry', () => { - const { textActions, insertTools } = useToolbarActions(jest.fn()); - expect(names(textActions)).toEqual(expect.arrayContaining(['bold', 'italic'])); + it('still offers the insert tools a QTI item can carry', () => { + const { insertTools } = useToolbarActions(jest.fn()); expect(names(insertTools)).toEqual(expect.arrayContaining(['image', 'math', 'code'])); }); }); diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index 3de6532f61..154c68e99c 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -358,26 +358,157 @@ def test_image_is_not_stripped(self): self.assertIn('alt, so the decoration travels as a + # style on a , which the profile does have. result = self._convert("It is ~~not~~ four.") - self.assertIn("

It is not four.

", result.xml) + 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_raw_html_marks_are_stripped(self): - # Every inline mark the renderer passes through as raw HTML that the QTI - # 3.0 HTML profile has no element for. - for tag in ("s", "del", "ins", "u", "mark", "strike"): + def test_raw_html_decorations_become_decorated_spans(self): + # Every inline decoration the renderer passes through as raw HTML, with + # the style a browser renders the tag with. + cases = ( + ("s", "line-through"), + ("del", "line-through"), + ("strike", "line-through"), + ("u", "underline"), + ("ins", "underline"), + ) + for tag, decoration in cases: with self.subTest(tag=tag): result = self._convert(f"It is <{tag}>not four.") - self.assertIn("

It is not four.

", result.xml) + self.assertIn( + f'

It is not' + " four.

", + result.xml, + ) self.assertNotIn(f"<{tag}>", result.xml) self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + def test_a_space_between_two_decorated_runs_survives(self): + # Whitespace between inline elements is a word gap the reader sees. Until the + # decorations became elements of their own, strip_tags flattened them into the + # surrounding text and the gap came along with it. + result = self._convert("It is ~~wrong~~ __under__ here.") + + self.assertIn( + '

It is wrong' + ' under here.

', + result.xml, + ) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_a_space_between_two_raw_decoration_tags_survives(self): + result = self._convert("It is a b here.") + + self.assertIn( + '

It is a' + ' b here.

', + result.xml, + ) + + def test_a_space_between_two_emphasis_runs_survives(self): + result = self._convert("It is **a** *b* here.") + + self.assertIn("

It is a b here.

", result.xml) + + def test_a_space_before_inline_math_survives(self): + # Math is not an HTML inline element, but the schema's InlineGroup lets it sit + # inline all the same, so the gap before it is a gap the reader sees. + result = self._convert("It is ~~wrong~~ $$x$$ here.") + + # No validity assertion here: inline math does not validate against the item + # XSD on unstable either, because the is serialized into the QTI + # namespace rather than the MathML one. That is its own bug, not this gap. + self.assertIn( + '

It is wrong It is wrong

  • one
  • two
  • ", 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

    ") == "~~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." ), "![alt text]({})".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