Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 46 additions & 16 deletions client/components/editor/common/katex.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
// Unicode Private Use Area characters to temporarily replace special
// characters during markdown parsing:
// - braces: prevent markdown-it-attrs from interpreting them as attribute
// delimiters.
// - pipe: prevent markdown table parser from interpreting them as cell
// delimiters.
const BRACE_OPEN_PLACEHOLDER = '\uE000'
const BRACE_CLOSE_PLACEHOLDER = '\uE001'
const PIPE_PLACEHOLDER = '\uE002'
const AMPERSAND_PLACEHOLDER = '\uE003'

export function restoreBraces (str) {
return str
.replaceAll(BRACE_OPEN_PLACEHOLDER, '{')
.replaceAll(BRACE_CLOSE_PLACEHOLDER, '}')
.replaceAll(PIPE_PLACEHOLDER, '|')
.replaceAll(AMPERSAND_PLACEHOLDER, '&')
}

// Test if potential opening or closing delimieter
// Assumes that there is a "$" at state.src[pos]
function isValidDelim (state, pos) {
Expand Down Expand Up @@ -27,6 +46,8 @@ function isValidDelim (state, pos) {
}

export default {
restoreBraces,

katexInline (state, silent) {
let start, match, token, res, pos

Expand Down Expand Up @@ -84,11 +105,13 @@ export default {
token.content = state.src
// Extract the math part without the $
.slice(start, match)
// Escape the curly braces since they will be interpreted as
// attributes by markdown-it-attrs (the "curly_attributes"
// core rule)
.replaceAll("{", "{{")
.replaceAll("}", "}}")
// Replace curly braces with temporary placeholders to prevent
// markdown-it-attrs from interpreting them as attribute delimiters.
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
// Replace pipe with temporary placeholder to prevent markdown
// table parser from interpreting it as a cell delimiter.
.replaceAll('|', PIPE_PLACEHOLDER)
}

state.pos = match + 1
Expand Down Expand Up @@ -133,15 +156,22 @@ export default {
}
}

state.line = next + 1

token = state.push('katex_block', 'math', 0)
token.block = true
token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') +
state.getLines(start + 1, next, state.tShift[start], true) +
(lastLine && lastLine.trim() ? lastLine : '')
token.map = [ start, state.line ]
token.markup = '$$'
return true
}
state.line = next + 1

token = state.push('katex_block', 'math', 0)
token.block = true
token.content = ((firstLine && firstLine.trim() ? firstLine + '\n' : '') +
state.getLines(start + 1, next, state.tShift[start], true) +
(lastLine && lastLine.trim() ? lastLine : ''))
// Replace curly braces with temporary placeholders to prevent
// markdown-it-attrs from interpreting them as attribute delimiters.
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
// Replace pipe with temporary placeholder to prevent markdown
// table parser from interpreting it as a cell delimiter.
.replaceAll('|', PIPE_PLACEHOLDER)
token.map = [ start, state.line ]
token.markup = '$$'
return true
}
}
58 changes: 55 additions & 3 deletions client/components/editor/editor-markdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,56 @@ DOMPurify.addHook('uponSanitizeElement', (elm) => {
// HELPER FUNCTIONS
// ========================================

// Unicode Private Use Area characters to temporarily replace special
// characters inside math expressions:
// - pipe (|): prevent markdown table parser from interpreting them as cell
// delimiters.
// - ampersand (&): prevent markdown-it-multimd-table from interpreting them
// as cell delimiters in multiline tables.
const PIPE_PLACEHOLDER = '\uE002'
const AMPERSAND_PLACEHOLDER = '\uE003'

/**
* Replace pipe and ampersand characters inside inline ($...$) and block
* ($$...$$) math expressions with placeholders to prevent markdown table
* parsers from splitting formulas containing | (e.g., |x|) or &
* (e.g., \begin{cases} ... & ... \\ ... \end{cases}).
*/
function protectMathPipes (text) {
let result = ''
let i = 0
while (i < text.length) {
// Check for block math ($$...$$)
if (text.slice(i, i + 2) === '$$') {
const end = text.indexOf('$$', i + 2)
if (end !== -1) {
result += text.slice(i, end + 2)
.replace(/\|/g, PIPE_PLACEHOLDER)
.replace(/&/g, AMPERSAND_PLACEHOLDER)
i = end + 2
continue
}
}
// Check for inline math ($...$) - must not span multiple lines
if (text[i] === '$' && text[i + 1] !== '$') {
// Only search for closing $ on the same line
const lineEnd = text.indexOf('\n', i + 1)
const searchEnd = lineEnd === -1 ? text.length : lineEnd
const end = text.indexOf('$', i + 1)
if (end !== -1 && end < searchEnd) {
result += text.slice(i, end + 1)
.replace(/\|/g, PIPE_PLACEHOLDER)
.replace(/&/g, AMPERSAND_PLACEHOLDER)
i = end + 1
continue
}
}
result += text[i]
i++
}
return result
}

// Inject line numbers for preview scroll sync
let linesMap = []
function injectLineNumbers (tokens, idx, options, env, slf) {
Expand Down Expand Up @@ -328,7 +378,7 @@ const macros = {}
md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline)
md.renderer.rules.katex_inline = (tokens, idx) => {
try {
return katex.renderToString(tokens[idx].content, {
return katex.renderToString(katexHelper.restoreBraces(tokens[idx].content), {
displayMode: false, macros
})
} catch (err) {
Expand All @@ -341,7 +391,7 @@ md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, {
})
md.renderer.rules.katex_block = (tokens, idx) => {
try {
return `<p>` + katex.renderToString(tokens[idx].content, {
return `<p>` + katex.renderToString(katexHelper.restoreBraces(tokens[idx].content), {
displayMode: true, macros
}) + `</p>`
} catch (err) {
Expand Down Expand Up @@ -453,7 +503,9 @@ export default {
linesMap = []
// this.$store.set('editor/content', newContent)
this.processMarkers(this.cm.firstLine(), this.cm.lastLine())
this.previewHTML = DOMPurify.sanitize(md.render(newContent), {
// Protect pipe characters inside math expressions before markdown parsing
const protectedContent = protectMathPipes(newContent)
this.previewHTML = DOMPurify.sanitize(md.render(protectedContent), {
ADD_TAGS: ['foreignObject'],
HTML_INTEGRATION_POINTS: { foreignobject: true }
})
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
"js-yaml": "3.14.0",
"jsdom": "16.4.0",
"jsonwebtoken": "9.0.3",
"katex": "0.12.0",
"katex": "0.16.46",
"klaw": "3.0.0",
"knex": "0.21.7",
"lodash": "4.17.21",
Expand Down
54 changes: 53 additions & 1 deletion server/modules/rendering/markdown-core/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,56 @@ const quoteStyles = {
Swedish: '””’’'
}

// Unicode Private Use Area characters to temporarily replace special
// characters inside math expressions:
// - pipe (|): prevent markdown table parser from interpreting them as cell
// delimiters.
// - ampersand (&): prevent markdown table parser from interpreting them
// as cell delimiters in multiline tables.
const PIPE_PLACEHOLDER = '\uE002'
const AMPERSAND_PLACEHOLDER = '\uE003'

/**
* Replace pipe and ampersand characters inside inline ($...$) and block
* ($$...$$) math expressions with placeholders to prevent markdown table
* parsers from splitting formulas containing | (e.g., |x|) or &
* (e.g., \begin{cases} ... & ... \\ ... \end{cases}).
*/
function protectMathPipes (text) {
let result = ''
let i = 0
while (i < text.length) {
// Check for block math ($$...$$)
if (text.slice(i, i + 2) === '$$') {
const end = text.indexOf('$$', i + 2)
if (end !== -1) {
result += text.slice(i, end + 2)
.replace(/\|/g, PIPE_PLACEHOLDER)
.replace(/&/g, AMPERSAND_PLACEHOLDER)
i = end + 2
continue
}
}
// Check for inline math ($...$) - must not span multiple lines
if (text[i] === '$' && text[i + 1] !== '$') {
// Only search for closing $ on the same line
const lineEnd = text.indexOf('\n', i + 1)
const searchEnd = lineEnd === -1 ? text.length : lineEnd
const end = text.indexOf('$', i + 1)
if (end !== -1 && end < searchEnd) {
result += text.slice(i, end + 1)
.replace(/\|/g, PIPE_PLACEHOLDER)
.replace(/&/g, AMPERSAND_PLACEHOLDER)
i = end + 1
continue
}
}
result += text[i]
i++
}
return result
}

module.exports = {
async render() {
const mkdown = md({
Expand Down Expand Up @@ -50,6 +100,8 @@ module.exports = {
await renderer.init(mkdown, child.config)
}

return mkdown.render(this.input)
// Protect pipe characters inside math expressions before markdown parsing
const protectedInput = protectMathPipes(this.input)
return mkdown.render(protectedInput)
}
}
52 changes: 43 additions & 9 deletions server/modules/rendering/markdown-katex/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ const chemParse = require('./mhchem')

/* global WIKI */

// Unicode Private Use Area characters to temporarily replace special
// characters during markdown parsing:
// - braces: prevent markdown-it-attrs from interpreting them as attribute
// delimiters.
// - pipe: prevent markdown table parser from interpreting them as cell
// delimiters.
const BRACE_OPEN_PLACEHOLDER = '\uE000'
const BRACE_CLOSE_PLACEHOLDER = '\uE001'
const PIPE_PLACEHOLDER = '\uE002'
const AMPERSAND_PLACEHOLDER = '\uE003'

function restoreBraces (str) {
return str
.replaceAll(BRACE_OPEN_PLACEHOLDER, '{')
.replaceAll(BRACE_CLOSE_PLACEHOLDER, '}')
.replaceAll(PIPE_PLACEHOLDER, '|')
.replaceAll(AMPERSAND_PLACEHOLDER, '&')
}

// ------------------------------------
// Markdown - KaTeX Renderer
// ------------------------------------
Expand All @@ -29,7 +48,7 @@ module.exports = {
mdinst.inline.ruler.after('escape', 'katex_inline', katexInline)
mdinst.renderer.rules.katex_inline = (tokens, idx) => {
try {
return katex.renderToString(tokens[idx].content, {
return katex.renderToString(restoreBraces(tokens[idx].content), {
displayMode: false, macros
})
} catch (err) {
Expand All @@ -44,7 +63,7 @@ module.exports = {
})
mdinst.renderer.rules.katex_block = (tokens, idx) => {
try {
return `<p>` + katex.renderToString(tokens[idx].content, {
return `<p>` + katex.renderToString(restoreBraces(tokens[idx].content), {
displayMode: true, macros
}) + `</p>`
} catch (err) {
Expand Down Expand Up @@ -135,11 +154,19 @@ function katexInline (state, silent) {
return true
}

if (!silent) {
token = state.push('katex_inline', 'math', 0)
token.markup = '$'
token.content = state.src.slice(start, match)
}
if (!silent) {
token = state.push('katex_inline', 'math', 0)
token.markup = '$'
token.content = state.src
.slice(start, match)
// Replace curly braces with temporary placeholders to prevent
// markdown-it-attrs from interpreting them as attribute delimiters.
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
// Replace pipe with temporary placeholder to prevent markdown
// table parser from interpreting it as a cell delimiter.
.replaceAll('|', PIPE_PLACEHOLDER)
}

state.pos = match + 1
return true
Expand Down Expand Up @@ -187,9 +214,16 @@ function katexBlock (state, start, end, silent) {

token = state.push('katex_block', 'math', 0)
token.block = true
token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') +
token.content = ((firstLine && firstLine.trim() ? firstLine + '\n' : '') +
state.getLines(start + 1, next, state.tShift[start], true) +
(lastLine && lastLine.trim() ? lastLine : '')
(lastLine && lastLine.trim() ? lastLine : ''))
// Replace curly braces with temporary placeholders to prevent
// markdown-it-attrs from interpreting them as attribute delimiters.
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
// Replace pipe with temporary placeholder to prevent markdown
// table parser from interpreting it as a cell delimiter.
.replaceAll('|', PIPE_PLACEHOLDER)
token.map = [ start, state.line ]
token.markup = '$$'
return true
Expand Down
Loading