Skip to content

Commit b41c9cd

Browse files
hugoguclaude
andcommitted
fix: protect special characters in math from markdown table parsers
Wiki.js uses markdown-it-attrs which interprets curly braces inside inline math ($...$) as attribute directives, stripping them from the formula. Additionally, markdown table parsers split cells at both `|` and `&` characters, breaking formulas containing those symbols. This fix replaces `{`, `}`, `|`, and `&` inside math expressions with Unicode Private Use Area placeholders during markdown parsing, then restores them before passing to KaTeX/MathJax for rendering. - `<E000>` / `<E001>`: temporary replacements for `{` / `}` - `<E002>`: temporary replacement for `|` (table cell delimiter) - `<E003>`: temporary replacement for `&` (table cell delimiter in multiline tables, used by LaTeX cases/arrays) The placeholder approach was chosen over HTML escaping because it preserves LaTeX environments like `\begin{array}` that were broken by the previous `{{}}` escaping method. Fixes #1581 Fixes #1462 Co-authored-by: Claude <noreply@anthropic.com> AI-model: kimi-for-coding/k2p6
1 parent 6f042e9 commit b41c9cd

5 files changed

Lines changed: 230 additions & 34 deletions

File tree

client/components/editor/common/katex.js

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
// Unicode Private Use Area characters to temporarily replace special
2+
// characters during markdown parsing:
3+
// - braces: prevent markdown-it-attrs from interpreting them as attribute
4+
// delimiters.
5+
// - pipe: prevent markdown table parser from interpreting them as cell
6+
// delimiters.
7+
const BRACE_OPEN_PLACEHOLDER = '\uE000'
8+
const BRACE_CLOSE_PLACEHOLDER = '\uE001'
9+
const PIPE_PLACEHOLDER = '\uE002'
10+
const AMPERSAND_PLACEHOLDER = '\uE003'
11+
12+
export function restoreBraces (str) {
13+
return str
14+
.replaceAll(BRACE_OPEN_PLACEHOLDER, '{')
15+
.replaceAll(BRACE_CLOSE_PLACEHOLDER, '}')
16+
.replaceAll(PIPE_PLACEHOLDER, '|')
17+
.replaceAll(AMPERSAND_PLACEHOLDER, '&')
18+
}
19+
120
// Test if potential opening or closing delimieter
221
// Assumes that there is a "$" at state.src[pos]
322
function isValidDelim (state, pos) {
@@ -27,6 +46,8 @@ function isValidDelim (state, pos) {
2746
}
2847

2948
export default {
49+
restoreBraces,
50+
3051
katexInline (state, silent) {
3152
let start, match, token, res, pos
3253

@@ -84,11 +105,13 @@ export default {
84105
token.content = state.src
85106
// Extract the math part without the $
86107
.slice(start, match)
87-
// Escape the curly braces since they will be interpreted as
88-
// attributes by markdown-it-attrs (the "curly_attributes"
89-
// core rule)
90-
.replaceAll("{", "{{")
91-
.replaceAll("}", "}}")
108+
// Replace curly braces with temporary placeholders to prevent
109+
// markdown-it-attrs from interpreting them as attribute delimiters.
110+
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
111+
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
112+
// Replace pipe with temporary placeholder to prevent markdown
113+
// table parser from interpreting it as a cell delimiter.
114+
.replaceAll('|', PIPE_PLACEHOLDER)
92115
}
93116

94117
state.pos = match + 1
@@ -133,15 +156,22 @@ export default {
133156
}
134157
}
135158

136-
state.line = next + 1
137-
138-
token = state.push('katex_block', 'math', 0)
139-
token.block = true
140-
token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') +
141-
state.getLines(start + 1, next, state.tShift[start], true) +
142-
(lastLine && lastLine.trim() ? lastLine : '')
143-
token.map = [ start, state.line ]
144-
token.markup = '$$'
145-
return true
146-
}
159+
state.line = next + 1
160+
161+
token = state.push('katex_block', 'math', 0)
162+
token.block = true
163+
token.content = ((firstLine && firstLine.trim() ? firstLine + '\n' : '') +
164+
state.getLines(start + 1, next, state.tShift[start], true) +
165+
(lastLine && lastLine.trim() ? lastLine : ''))
166+
// Replace curly braces with temporary placeholders to prevent
167+
// markdown-it-attrs from interpreting them as attribute delimiters.
168+
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
169+
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
170+
// Replace pipe with temporary placeholder to prevent markdown
171+
// table parser from interpreting it as a cell delimiter.
172+
.replaceAll('|', PIPE_PLACEHOLDER)
173+
token.map = [ start, state.line ]
174+
token.markup = '$$'
175+
return true
176+
}
147177
}

client/components/editor/editor-markdown.vue

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,53 @@ DOMPurify.addHook('uponSanitizeElement', (elm) => {
296296
// HELPER FUNCTIONS
297297
// ========================================
298298
299+
// Unicode Private Use Area characters to temporarily replace special
300+
// characters inside math expressions:
301+
// - pipe (|): prevent markdown table parser from interpreting them as cell
302+
// delimiters.
303+
// - ampersand (&): prevent markdown-it-multimd-table from interpreting them
304+
// as cell delimiters in multiline tables.
305+
const PIPE_PLACEHOLDER = '\uE002'
306+
const AMPERSAND_PLACEHOLDER = '\uE003'
307+
308+
/**
309+
* Replace pipe and ampersand characters inside inline ($...$) and block
310+
* ($$...$$) math expressions with placeholders to prevent markdown table
311+
* parsers from splitting formulas containing | (e.g., |x|) or &
312+
* (e.g., \begin{cases} ... & ... \\ ... \end{cases}).
313+
*/
314+
function protectMathPipes (text) {
315+
let result = ''
316+
let i = 0
317+
while (i < text.length) {
318+
// Check for block math ($$...$$)
319+
if (text.slice(i, i + 2) === '$$') {
320+
const end = text.indexOf('$$', i + 2)
321+
if (end !== -1) {
322+
result += text.slice(i, end + 2)
323+
.replace(/\|/g, PIPE_PLACEHOLDER)
324+
.replace(/&/g, AMPERSAND_PLACEHOLDER)
325+
i = end + 2
326+
continue
327+
}
328+
}
329+
// Check for inline math ($...$)
330+
if (text[i] === '$' && text[i + 1] !== '$') {
331+
const end = text.indexOf('$', i + 1)
332+
if (end !== -1) {
333+
result += text.slice(i, end + 1)
334+
.replace(/\|/g, PIPE_PLACEHOLDER)
335+
.replace(/&/g, AMPERSAND_PLACEHOLDER)
336+
i = end + 1
337+
continue
338+
}
339+
}
340+
result += text[i]
341+
i++
342+
}
343+
return result
344+
}
345+
299346
// Inject line numbers for preview scroll sync
300347
let linesMap = []
301348
function injectLineNumbers (tokens, idx, options, env, slf) {
@@ -328,7 +375,7 @@ const macros = {}
328375
md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline)
329376
md.renderer.rules.katex_inline = (tokens, idx) => {
330377
try {
331-
return katex.renderToString(tokens[idx].content, {
378+
return katex.renderToString(katexHelper.restoreBraces(tokens[idx].content), {
332379
displayMode: false, macros
333380
})
334381
} catch (err) {
@@ -341,7 +388,7 @@ md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, {
341388
})
342389
md.renderer.rules.katex_block = (tokens, idx) => {
343390
try {
344-
return `<p>` + katex.renderToString(tokens[idx].content, {
391+
return `<p>` + katex.renderToString(katexHelper.restoreBraces(tokens[idx].content), {
345392
displayMode: true, macros
346393
}) + `</p>`
347394
} catch (err) {
@@ -453,7 +500,9 @@ export default {
453500
linesMap = []
454501
// this.$store.set('editor/content', newContent)
455502
this.processMarkers(this.cm.firstLine(), this.cm.lastLine())
456-
this.previewHTML = DOMPurify.sanitize(md.render(newContent), {
503+
// Protect pipe characters inside math expressions before markdown parsing
504+
const protectedContent = protectMathPipes(newContent)
505+
this.previewHTML = DOMPurify.sanitize(md.render(protectedContent), {
457506
ADD_TAGS: ['foreignObject'],
458507
HTML_INTEGRATION_POINTS: { foreignobject: true }
459508
})

server/modules/rendering/markdown-core/renderer.js

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,53 @@ const quoteStyles = {
1919
Swedish: '””’’'
2020
}
2121

22+
// Unicode Private Use Area characters to temporarily replace special
23+
// characters inside math expressions:
24+
// - pipe (|): prevent markdown table parser from interpreting them as cell
25+
// delimiters.
26+
// - ampersand (&): prevent markdown table parser from interpreting them
27+
// as cell delimiters in multiline tables.
28+
const PIPE_PLACEHOLDER = '\uE002'
29+
const AMPERSAND_PLACEHOLDER = '\uE003'
30+
31+
/**
32+
* Replace pipe and ampersand characters inside inline ($...$) and block
33+
* ($$...$$) math expressions with placeholders to prevent markdown table
34+
* parsers from splitting formulas containing | (e.g., |x|) or &
35+
* (e.g., \begin{cases} ... & ... \\ ... \end{cases}).
36+
*/
37+
function protectMathPipes (text) {
38+
let result = ''
39+
let i = 0
40+
while (i < text.length) {
41+
// Check for block math ($$...$$)
42+
if (text.slice(i, i + 2) === '$$') {
43+
const end = text.indexOf('$$', i + 2)
44+
if (end !== -1) {
45+
result += text.slice(i, end + 2)
46+
.replace(/\|/g, PIPE_PLACEHOLDER)
47+
.replace(/&/g, AMPERSAND_PLACEHOLDER)
48+
i = end + 2
49+
continue
50+
}
51+
}
52+
// Check for inline math ($...$)
53+
if (text[i] === '$' && text[i + 1] !== '$') {
54+
const end = text.indexOf('$', i + 1)
55+
if (end !== -1) {
56+
result += text.slice(i, end + 1)
57+
.replace(/\|/g, PIPE_PLACEHOLDER)
58+
.replace(/&/g, AMPERSAND_PLACEHOLDER)
59+
i = end + 1
60+
continue
61+
}
62+
}
63+
result += text[i]
64+
i++
65+
}
66+
return result
67+
}
68+
2269
module.exports = {
2370
async render() {
2471
const mkdown = md({
@@ -50,6 +97,8 @@ module.exports = {
5097
await renderer.init(mkdown, child.config)
5198
}
5299

53-
return mkdown.render(this.input)
100+
// Protect pipe characters inside math expressions before markdown parsing
101+
const protectedInput = protectMathPipes(this.input)
102+
return mkdown.render(protectedInput)
54103
}
55104
}

server/modules/rendering/markdown-katex/renderer.js

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@ const chemParse = require('./mhchem')
33

44
/* global WIKI */
55

6+
// Unicode Private Use Area characters to temporarily replace special
7+
// characters during markdown parsing:
8+
// - braces: prevent markdown-it-attrs from interpreting them as attribute
9+
// delimiters.
10+
// - pipe: prevent markdown table parser from interpreting them as cell
11+
// delimiters.
12+
const BRACE_OPEN_PLACEHOLDER = '\uE000'
13+
const BRACE_CLOSE_PLACEHOLDER = '\uE001'
14+
const PIPE_PLACEHOLDER = '\uE002'
15+
const AMPERSAND_PLACEHOLDER = '\uE003'
16+
17+
function restoreBraces (str) {
18+
return str
19+
.replaceAll(BRACE_OPEN_PLACEHOLDER, '{')
20+
.replaceAll(BRACE_CLOSE_PLACEHOLDER, '}')
21+
.replaceAll(PIPE_PLACEHOLDER, '|')
22+
.replaceAll(AMPERSAND_PLACEHOLDER, '&')
23+
}
24+
625
// ------------------------------------
726
// Markdown - KaTeX Renderer
827
// ------------------------------------
@@ -29,7 +48,7 @@ module.exports = {
2948
mdinst.inline.ruler.after('escape', 'katex_inline', katexInline)
3049
mdinst.renderer.rules.katex_inline = (tokens, idx) => {
3150
try {
32-
return katex.renderToString(tokens[idx].content, {
51+
return katex.renderToString(restoreBraces(tokens[idx].content), {
3352
displayMode: false, macros
3453
})
3554
} catch (err) {
@@ -44,7 +63,7 @@ module.exports = {
4463
})
4564
mdinst.renderer.rules.katex_block = (tokens, idx) => {
4665
try {
47-
return `<p>` + katex.renderToString(tokens[idx].content, {
66+
return `<p>` + katex.renderToString(restoreBraces(tokens[idx].content), {
4867
displayMode: true, macros
4968
}) + `</p>`
5069
} catch (err) {
@@ -135,11 +154,19 @@ function katexInline (state, silent) {
135154
return true
136155
}
137156

138-
if (!silent) {
139-
token = state.push('katex_inline', 'math', 0)
140-
token.markup = '$'
141-
token.content = state.src.slice(start, match)
142-
}
157+
if (!silent) {
158+
token = state.push('katex_inline', 'math', 0)
159+
token.markup = '$'
160+
token.content = state.src
161+
.slice(start, match)
162+
// Replace curly braces with temporary placeholders to prevent
163+
// markdown-it-attrs from interpreting them as attribute delimiters.
164+
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
165+
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
166+
// Replace pipe with temporary placeholder to prevent markdown
167+
// table parser from interpreting it as a cell delimiter.
168+
.replaceAll('|', PIPE_PLACEHOLDER)
169+
}
143170

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

188215
token = state.push('katex_block', 'math', 0)
189216
token.block = true
190-
token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') +
217+
token.content = ((firstLine && firstLine.trim() ? firstLine + '\n' : '') +
191218
state.getLines(start + 1, next, state.tShift[start], true) +
192-
(lastLine && lastLine.trim() ? lastLine : '')
219+
(lastLine && lastLine.trim() ? lastLine : ''))
220+
// Replace curly braces with temporary placeholders to prevent
221+
// markdown-it-attrs from interpreting them as attribute delimiters.
222+
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
223+
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
224+
// Replace pipe with temporary placeholder to prevent markdown
225+
// table parser from interpreting it as a cell delimiter.
226+
.replaceAll('|', PIPE_PLACEHOLDER)
193227
token.map = [ start, state.line ]
194228
token.markup = '$$'
195229
return true

server/modules/rendering/markdown-mathjax/renderer.js

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@ const mjax = require('mathjax')
22

33
/* global WIKI */
44

5+
// Unicode Private Use Area characters to temporarily replace special
6+
// characters during markdown parsing:
7+
// - braces: prevent markdown-it-attrs from interpreting them as attribute
8+
// delimiters.
9+
// - pipe: prevent markdown table parser from interpreting them as cell
10+
// delimiters.
11+
const BRACE_OPEN_PLACEHOLDER = '\uE000'
12+
const BRACE_CLOSE_PLACEHOLDER = '\uE001'
13+
const PIPE_PLACEHOLDER = '\uE002'
14+
const AMPERSAND_PLACEHOLDER = '\uE003'
15+
16+
function restoreBraces (str) {
17+
return str
18+
.replaceAll(BRACE_OPEN_PLACEHOLDER, '{')
19+
.replaceAll(BRACE_CLOSE_PLACEHOLDER, '}')
20+
.replaceAll(PIPE_PLACEHOLDER, '|')
21+
.replaceAll(AMPERSAND_PLACEHOLDER, '&')
22+
}
23+
524
// ------------------------------------
625
// Markdown - MathJax Renderer
726
// ------------------------------------
@@ -38,7 +57,7 @@ module.exports = {
3857
mdinst.inline.ruler.after('escape', 'mathjax_inline', mathjaxInline)
3958
mdinst.renderer.rules.mathjax_inline = (tokens, idx) => {
4059
try {
41-
const result = MathJax.tex2svg(tokens[idx].content, {
60+
const result = MathJax.tex2svg(restoreBraces(tokens[idx].content), {
4261
display: false
4362
})
4463
return MathJax.startup.adaptor.innerHTML(result)
@@ -54,7 +73,7 @@ module.exports = {
5473
})
5574
mdinst.renderer.rules.mathjax_block = (tokens, idx) => {
5675
try {
57-
const result = MathJax.tex2svg(tokens[idx].content, {
76+
const result = MathJax.tex2svg(restoreBraces(tokens[idx].content), {
5877
display: true
5978
})
6079
return `<p>` + MathJax.startup.adaptor.innerHTML(result) + `</p>`
@@ -149,7 +168,15 @@ function mathjaxInline (state, silent) {
149168
if (!silent) {
150169
token = state.push('mathjax_inline', 'math', 0)
151170
token.markup = '$'
152-
token.content = state.src.slice(start, match)
171+
token.content = state.src
172+
.slice(start, match)
173+
// Replace curly braces with temporary placeholders to prevent
174+
// markdown-it-attrs from interpreting them as attribute delimiters.
175+
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
176+
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
177+
// Replace pipe with temporary placeholder to prevent markdown
178+
// table parser from interpreting it as a cell delimiter.
179+
.replaceAll('|', PIPE_PLACEHOLDER)
153180
}
154181

155182
state.pos = match + 1
@@ -198,9 +225,16 @@ function mathjaxBlock (state, start, end, silent) {
198225

199226
token = state.push('mathjax_block', 'math', 0)
200227
token.block = true
201-
token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') +
228+
token.content = ((firstLine && firstLine.trim() ? firstLine + '\n' : '') +
202229
state.getLines(start + 1, next, state.tShift[start], true) +
203-
(lastLine && lastLine.trim() ? lastLine : '')
230+
(lastLine && lastLine.trim() ? lastLine : ''))
231+
// Replace curly braces with temporary placeholders to prevent
232+
// markdown-it-attrs from interpreting them as attribute delimiters.
233+
.replaceAll('{', BRACE_OPEN_PLACEHOLDER)
234+
.replaceAll('}', BRACE_CLOSE_PLACEHOLDER)
235+
// Replace pipe with temporary placeholder to prevent markdown
236+
// table parser from interpreting it as a cell delimiter.
237+
.replaceAll('|', PIPE_PLACEHOLDER)
204238
token.map = [ start, state.line ]
205239
token.markup = '$$'
206240
return true

0 commit comments

Comments
 (0)