Skip to content

Commit 947d43e

Browse files
ArkciaTheDragonCopilotsunsonliu
authored
feat: 支持 \(...\) 与 \[...\] 公式定界符 (#1879)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: sunsonliu <sunsonliu@tencent.com>
1 parent e803d07 commit 947d43e

8 files changed

Lines changed: 227 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'cherry-markdown': patch
3+
---
4+
5+
feat: 支持 `\(...\)` 行内公式与 `\[...\]` 块级公式定界符

‎packages/cherry-markdown/src/core/hooks/InlineMath.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getHTML } from '@/utils/dom';
1919
import { isBrowser } from '@/utils/env';
2020
import { getTableRule, isLookbehindSupported, mathBlockReg } from '@/utils/regexp';
2121
import { replaceLookbehind } from '@/utils/lookbehind-replace';
22+
import { normalizeMathDelimiters } from '@/utils/mathDelimiter';
2223

2324
/**
2425
* 行内公式的语法
@@ -128,14 +129,21 @@ export default class InlineMath extends ParagraphBase {
128129
}
129130

130131
beforeMakeHtml(str) {
131-
let $str = str;
132-
// 格里处理行内公式,让一个td里的行内公式语法生效,让跨td的行内公式语法失效
133-
$str = $str.replace(getTableRule(true), (whole, ...args) => {
132+
let result = '';
133+
let cursor = 0;
134+
// 表格里处理行内公式,让一个td里的行内公式语法生效,让跨td的行内公式语法失效
135+
str.replace(getTableRule(true), (whole, ...args) => {
136+
const offset = args[args.length - 2];
137+
result += this.makeInlineMathWithSelfClosing(this.normalizeTexInlineMath(str.slice(cursor, offset)));
134138
const arr = whole.split('|');
135-
return arr
139+
result += arr
136140
.map((oneTd, index) => {
141+
const normalizedTd = normalizeMathDelimiters(oneTd, {
142+
'\\(': { replacement: '~D', selfClosing: index === arr.length - 1 && this.isSelfClosing() },
143+
'\\[': { replacement: '~D~D' },
144+
});
137145
// 单元格里的段落公式直接替换成行内公式
138-
const tdContent = this.transformBlockMathToInlineMath(oneTd);
146+
const tdContent = this.transformBlockMathToInlineMath(normalizedTd);
139147
// 判断是否为最后一个td
140148
if (index === arr.length - 1) {
141149
return this.makeInlineMathWithSelfClosing(tdContent);
@@ -145,9 +153,17 @@ export default class InlineMath extends ParagraphBase {
145153
.join('|')
146154
.replace(/\\~D/g, '~D') // 出现反斜杠的情况(如/$e=m^2$)会导致多一个反斜杠,这里替换掉
147155
.replace(/~D/g, '\\~D');
156+
cursor = offset + whole.length;
157+
return whole;
158+
});
159+
result += this.makeInlineMathWithSelfClosing(this.normalizeTexInlineMath(str.slice(cursor)));
160+
return result;
161+
}
162+
163+
normalizeTexInlineMath(str) {
164+
return normalizeMathDelimiters(str, {
165+
'\\(': { replacement: '~D', selfClosing: this.isSelfClosing() },
148166
});
149-
$str = this.makeInlineMathWithSelfClosing($str);
150-
return $str;
151167
}
152168

153169
makeInlineMathWithSelfClosing(str) {

‎packages/cherry-markdown/src/core/hooks/MathBlock.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getHTML } from '@/utils/dom';
1919
import { isBrowser } from '@/utils/env';
2020
import { isLookbehindSupported } from '@/utils/regexp';
2121
import { replaceLookbehind } from '@/utils/lookbehind-replace';
22+
import { normalizeMathDelimiters } from '@/utils/mathDelimiter';
2223

2324
export default class MathBlock extends ParagraphBase {
2425
static HOOK_NAME = 'mathBlock';
@@ -149,7 +150,10 @@ export default class MathBlock extends ParagraphBase {
149150
}
150151

151152
beforeMakeHtml(str) {
152-
let $str = this.makeMath(str);
153+
const normalizedStr = normalizeMathDelimiters(str, {
154+
'\\[': { replacement: '~D~D', selfClosing: this.isSelfClosing() },
155+
});
156+
let $str = this.makeMath(normalizedStr);
153157
if (this.isSelfClosing()) {
154158
const $oldStr = $str;
155159
$str = this.$dealUnclosingMath($str);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Copyright (C) 2021 Tencent.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import Link from '@/core/hooks/Link';
17+
18+
const linkRule = new Link({ config: {}, globalConfig: {} }).RULE.reg;
19+
const delimiterPairs = [
20+
{ open: '~D~D', close: '~D~D' },
21+
{ open: '~D', close: '~D' },
22+
{ open: '\\[', close: '\\]' },
23+
{ open: '\\(', close: '\\)' },
24+
];
25+
26+
function isEscaped(str, index) {
27+
let backslashCount = 0;
28+
for (let i = index - 1; i >= 0 && str[i] === '\\'; i -= 1) {
29+
backslashCount += 1;
30+
}
31+
return backslashCount % 2 === 1;
32+
}
33+
34+
function findUnescaped(str, delimiter, fromIndex) {
35+
let index = str.indexOf(delimiter, fromIndex);
36+
while (index !== -1 && isEscaped(str, index)) {
37+
index = str.indexOf(delimiter, index + delimiter.length);
38+
}
39+
return index;
40+
}
41+
42+
function getLinkDestinationRanges(str) {
43+
const ranges = [];
44+
str.replace(linkRule, (match, leadingChar, text, link, title, target, targetValue, offset) => {
45+
const destinationStart = match.indexOf(link, match.indexOf(']') + 1);
46+
if (destinationStart !== -1) {
47+
ranges.push({ start: offset + destinationStart, end: offset + match.length });
48+
}
49+
return match;
50+
});
51+
return ranges;
52+
}
53+
54+
/**
55+
* @param {string} str
56+
* @param {Record<string, { replacement: string, selfClosing?: boolean }>} replacements
57+
*/
58+
export function normalizeMathDelimiters(str, replacements) {
59+
let delimiters = delimiterPairs.map((delimiter) => ({
60+
...delimiter,
61+
...replacements[delimiter.open],
62+
}));
63+
let result = '';
64+
let cursor = 0;
65+
const linkDestinationRanges = getLinkDestinationRanges(str);
66+
67+
while (cursor < str.length) {
68+
let nextDelimiter;
69+
let openIndex = -1;
70+
for (const delimiter of delimiters) {
71+
const index = findUnescaped(str, delimiter.open, cursor);
72+
if (index !== -1 && (openIndex === -1 || index < openIndex)) {
73+
nextDelimiter = delimiter;
74+
openIndex = index;
75+
}
76+
}
77+
if (!nextDelimiter) {
78+
return result + str.slice(cursor);
79+
}
80+
81+
result += str.slice(cursor, openIndex);
82+
const contentStart = openIndex + nextDelimiter.open.length;
83+
const closeIndex = findUnescaped(str, nextDelimiter.close, contentStart);
84+
const isInLinkDestination = linkDestinationRanges.some(
85+
({ start, end }) => openIndex >= start && openIndex < end,
86+
);
87+
88+
if (closeIndex === -1) {
89+
const content = str.slice(contentStart);
90+
const hasContent = content.replace(/CHERRYFLOWSESSIONCURSOR\n*$/, '').trim();
91+
if (nextDelimiter.replacement && nextDelimiter.selfClosing && hasContent && !isInLinkDestination) {
92+
return result + nextDelimiter.replacement + content;
93+
}
94+
result += nextDelimiter.open;
95+
cursor = contentStart;
96+
delimiters = delimiters.filter((delimiter) => delimiter !== nextDelimiter);
97+
continue;
98+
}
99+
100+
const content = str.slice(contentStart, closeIndex);
101+
if (nextDelimiter.replacement && content.trim() && !isInLinkDestination) {
102+
result += `${nextDelimiter.replacement}${content}${nextDelimiter.replacement}`;
103+
} else {
104+
result += str.slice(openIndex, closeIndex + nextDelimiter.close.length);
105+
}
106+
cursor = closeIndex + nextDelimiter.close.length;
107+
}
108+
return result;
109+
}

‎packages/cherry-markdown/test/core/MarkdownRendering.spec.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,15 @@ describe('Cherry Markdown final rendering', () => {
292292
expect(decodeURIComponent(blockMath?.getAttribute('data-content') ?? '')).toBe('y = x + 1');
293293
});
294294

295+
it('renders TeX inline and display formula delimiters', () => {
296+
const container = render('Inline \\(x^2\\) formula.\n\n\\[\ny = x + 1\n\\]');
297+
const inlineMath = container.querySelector('.Cherry-InlineMath');
298+
const blockMath = container.querySelector('.Cherry-Math[data-type="mathBlock"]');
299+
300+
expect(decodeURIComponent(inlineMath?.getAttribute('data-formula-source') ?? '')).toBe('x^2');
301+
expect(decodeURIComponent(blockMath?.getAttribute('data-formula-source') ?? '')).toBe('y = x + 1');
302+
});
303+
295304
it('renders repeated footnote references and one definition', () => {
296305
const container = render('First[^note], second[^note].\n\n[^note]: **Footnote** content');
297306
const references = container.querySelectorAll('sup.cherry-footnote-number a');

‎packages/cherry-markdown/test/core/__snapshots__/CommonMark.spec.ts.snap‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ exports[`engine > CommonMark-11 1`] = `
5656
`;
5757

5858
exports[`engine > CommonMark-12 1`] = `
59-
"<p data-sign="b88e7b1f827aa4d21" data-type="p" data-lines="1">!&quot;#$%\\&amp;&#x27;()*+,-./:;&lt;=&gt;?@[\\]^_\`{|}~</p>
59+
"<p data-sign="0349be234f780c2b1" data-type="p" data-lines="1">!&quot;#$%\\&amp;&#x27;()*+,-./:;&lt;=&gt;?@</p><div data-sign="03758bb2cc688b63" class="Cherry-Math cherry-mathjax-need-render" data-type="mathBlock" data-formula-source="%5C%5C" data-lines="0" data-content="%5C%5C"></div><p data-sign="f2b1d347ae9fdbe21" data-type="p" data-lines="1">^_\`{|}~</p>
6060
"
6161
`;
6262

‎packages/cherry-markdown/test/core/hooks/InlineMath.spec.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,36 @@ describe('core/hooks/InlineMath', () => {
196196
expect(hook.makeInlineMath('plain text')).toBe('plain text');
197197
});
198198

199+
it('renders TeX inline formula delimiters directly', () => {
200+
const { hook } = createInlineMath('MathJax');
201+
hook.engine = 'node';
202+
203+
const html = hook.restoreCache(hook.beforeMakeHtml('value \\(x^2\\)'));
204+
205+
expect(html).toContain('$x\\^2$');
206+
expect(html).toContain('data-formula-source="x%5E2"');
207+
});
208+
209+
it('keeps escaped, empty, and unclosed TeX inline delimiters unchanged', () => {
210+
const { hook } = createInlineMath('MathJax');
211+
212+
expect(hook.beforeMakeHtml('\\\\(not math\\\\)')).toBe('\\\\(not math\\\\)');
213+
expect(hook.beforeMakeHtml('\\( \\)')).toBe('\\( \\)');
214+
expect(hook.beforeMakeHtml('before \\(unclosed')).toBe('before \\(unclosed');
215+
});
216+
217+
it('preserves TeX delimiters in existing formulas and link destinations', () => {
218+
const { hook } = createInlineMath('MathJax');
219+
hook.engine = 'node';
220+
221+
const formulaHtml = hook.restoreCache(hook.beforeMakeHtml('~D\\(x\\)~D'));
222+
const linkHtml = hook.restoreCache(hook.beforeMakeHtml('[\\(label\\)](\\(url\\) "title \\[text\\]")'));
223+
224+
expect(formulaHtml).toContain('data-formula-source="%5C(x%5C)"');
225+
expect(linkHtml).toContain('data-formula-source="label"');
226+
expect(linkHtml).toContain('(\\(url\\) "title \\[text\\]")');
227+
});
228+
199229
it('closes an unfinished inline formula in self-closing and flow modes', () => {
200230
const selfClosing = createInlineMath('MathJax', true).hook;
201231
const flow = createInlineMath('MathJax', false, true).hook;
@@ -209,6 +239,13 @@ describe('core/hooks/InlineMath', () => {
209239
expect(selfClosingHtml).toContain('data-formula-source="x%5E2"');
210240
expect(flowHtml).toContain('$x\\^2$');
211241
expect(flowHtml).toContain('CHERRYFLOWSESSIONCURSOR');
242+
243+
const texSelfClosingHtml = selfClosing.restoreCache(selfClosing.beforeMakeHtml('value \\(x^2'));
244+
const texFlowHtml = flow.restoreCache(flow.beforeMakeHtml('value \\(x^2CHERRYFLOWSESSIONCURSOR'));
245+
246+
expect(texSelfClosingHtml).toContain('data-formula-source="x%5E2"');
247+
expect(texFlowHtml).toContain('data-formula-source="x%5E2"');
248+
expect(texFlowHtml).toContain('CHERRYFLOWSESSIONCURSOR');
212249
});
213250

214251
it('turns block formulas inside table cells into inline formulas', () => {
@@ -234,4 +271,17 @@ describe('core/hooks/InlineMath', () => {
234271
expect(html).toContain('data-formula-source="x%2By"');
235272
expect(html).toContain('data-formula-source="z"');
236273
});
274+
275+
it('keeps TeX inline formulas within a single Markdown table cell', () => {
276+
const { hook } = createInlineMath('MathJax');
277+
hook.engine = 'node';
278+
const markdown = '| Left | Right |\n| --- | --- |\n| \\(x | y\\) |\n| \\(z\\) | plain |';
279+
280+
const html = hook.restoreCache(hook.beforeMakeHtml(markdown));
281+
282+
expect(html.match(/class="Cherry-InlineMath"/g)).toHaveLength(1);
283+
expect(html).toContain('data-formula-source="z"');
284+
expect(html).toContain('\\(x');
285+
expect(html).toContain('y\\)');
286+
});
237287
});

‎packages/cherry-markdown/test/core/hooks/MathBlock.spec.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,24 @@ describe('core/hooks/MathBlock', () => {
190190
expect(html.endsWith('tail')).toBe(false);
191191
});
192192

193+
it('renders TeX block formula delimiters directly', () => {
194+
const { hook } = createMathBlock('MathJax');
195+
hook.engine = 'node';
196+
197+
const html = hook.restoreCache(hook.beforeMakeHtml('\\[\nx^2\n\\]'));
198+
199+
expect(html).toContain('$$x\\^2$$');
200+
expect(html).toContain('data-formula-source="x%5E2"');
201+
});
202+
203+
it('keeps escaped, empty, and unclosed TeX block delimiters unchanged', () => {
204+
const { hook } = createMathBlock('MathJax');
205+
206+
expect(hook.beforeMakeHtml('\\\\[not math\\\\]')).toBe('\\\\[not math\\\\]');
207+
expect(hook.beforeMakeHtml('\\[\n\\]')).toBe('\\[\n\\]');
208+
expect(hook.beforeMakeHtml('before \\[unclosed')).toBe('before \\[unclosed');
209+
});
210+
193211
it('closes unfinished block formulas in self-closing and flow modes', () => {
194212
const selfClosing = createMathBlock('MathJax', true).hook;
195213
const flow = createMathBlock('MathJax', false, true).hook;
@@ -203,6 +221,13 @@ describe('core/hooks/MathBlock', () => {
203221
expect(selfClosingHtml).toContain('data-formula-source="x%5E2"');
204222
expect(flowHtml).toContain('$$x\\^2$$');
205223
expect(flowHtml).toContain('CHERRYFLOWSESSIONCURSOR');
224+
225+
const texSelfClosingHtml = selfClosing.restoreCache(selfClosing.beforeMakeHtml('\\[x^2'));
226+
const texFlowHtml = flow.restoreCache(flow.beforeMakeHtml('\\[x^2CHERRYFLOWSESSIONCURSOR'));
227+
228+
expect(texSelfClosingHtml).toContain('data-formula-source="x%5E2"');
229+
expect(texFlowHtml).toContain('data-formula-source="x%5E2"');
230+
expect(texFlowHtml).toContain('CHERRYFLOWSESSIONCURSOR');
206231
});
207232

208233
it('keeps escaped formula markers unchanged', () => {

0 commit comments

Comments
 (0)