diff --git a/tests/e2e/mobile-display-math.spec.ts b/tests/e2e/mobile-display-math.spec.ts
new file mode 100644
index 0000000..6d9c4ba
--- /dev/null
+++ b/tests/e2e/mobile-display-math.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/test'
+import { openLocalDraft } from './helpers/drafts'
+
+test.describe.configure({ timeout: 60000 })
+
+// Upstream marktext#4935 (issue #4904): `$$...$$` on the same line as other
+// text renders as display math instead of showing raw `$$` markers — markdown
+// pasted from pandoc/GitHub-style sources now displays correctly. The Muya
+// unit specs cover tokenization and serialization in happy-dom; this covers
+// what happy-dom cannot: the shipped CSS renders the hidden preview as a
+// full-width centered block in a real Chromium layout, while single-dollar
+// math stays inline.
+
+const MARKDOWN = 'Energy is $$E=mc^2$$ conserved, inline $x$ stays inline.'
+
+test('renders same-line double-dollar math as a centered display block', async ({ page }) => {
+ await openLocalDraft(page, {
+ id: 'display-math-draft',
+ markdown: MARKDOWN,
+ title: /Energy is/,
+ })
+
+ const display = page.locator('.mu-math.mu-display-math').first()
+ await expect(display).toBeVisible()
+ await expect(display.locator('.katex-display')).toHaveCount(1)
+
+ const layout = await display.evaluate(element => {
+ const paragraph = element.closest('p.mu-paragraph')!
+ const katex = element.querySelector('.katex')!
+ const elementRect = element.getBoundingClientRect()
+ const paragraphRect = paragraph.getBoundingClientRect()
+ const katexRect = katex.getBoundingClientRect()
+
+ return {
+ display: getComputedStyle(element).display,
+ widthDelta: Math.abs(elementRect.width - paragraphRect.width),
+ centerDelta: Math.abs(
+ katexRect.left + katexRect.width / 2 - (paragraphRect.left + paragraphRect.width / 2),
+ ),
+ }
+ })
+ expect(layout.display).toBe('block')
+ expect(layout.widthDelta).toBeLessThanOrEqual(2)
+ expect(layout.centerDelta).toBeLessThanOrEqual(2)
+
+ // Single-dollar math is untouched: inline preview, no display block.
+ const inline = page.locator('.mu-math:not(.mu-display-math)').first()
+ await expect(inline).toBeVisible()
+ await expect(inline.locator('.katex-display')).toHaveCount(0)
+})
diff --git a/third_party/muya/src/assets/styles/inlineSyntax.css b/third_party/muya/src/assets/styles/inlineSyntax.css
index 8a65ca3..83ff6bb 100644
--- a/third_party/muya/src/assets/styles/inlineSyntax.css
+++ b/third_party/muya/src/assets/styles/inlineSyntax.css
@@ -290,6 +290,13 @@ div .mu-math-error,
vertical-align: middle;
}
+.mu-hide.mu-math.mu-display-math {
+ display: block;
+ width: 100%;
+
+ text-align: center;
+}
+
.mu-hide.mu-ruby,
.mu-hide.mu-math {
width: auto;
diff --git a/third_party/muya/src/block/base/__tests__/formatToggle.spec.ts b/third_party/muya/src/block/base/__tests__/formatToggle.spec.ts
index c096bd7..5922364 100644
--- a/third_party/muya/src/block/base/__tests__/formatToggle.spec.ts
+++ b/third_party/muya/src/block/base/__tests__/formatToggle.spec.ts
@@ -113,6 +113,16 @@ describe('format.format() toggle-off with the caret inside the formatted run', (
expect(content.text).toBe('word');
});
+ it('display math: `$$word$$` removes both markers without shifting the selection', () => {
+ const content = selectInFirstBlock(bootMuya('$$word$$\n'), 2, 6);
+ const setCursor = vi.spyOn(content, 'setCursor');
+
+ content.format('inline_math');
+
+ expect(content.text).toBe('word');
+ expect(setCursor).toHaveBeenLastCalledWith(0, 4, true);
+ });
+
it('u (html_tag): `word` removes the underline tags', () => {
// `format('u')` matches the html_tag token whose tag === 'u'.
const content = caretInFirstBlock(bootMuya('word\n'), 2);
diff --git a/third_party/muya/src/block/base/format.ts b/third_party/muya/src/block/base/format.ts
index 7067da2..1f6cbe7 100644
--- a/third_party/muya/src/block/base/format.ts
+++ b/third_party/muya/src/block/base/format.ts
@@ -128,7 +128,12 @@ function getOffset(offset: number, token: Token) {
case 'inline_code':
case 'inline_math': {
- const markerLen = type === 'strong' || type === 'del' ? 2 : 1;
+ const markerLen
+ = type === 'strong' || type === 'del'
+ ? 2
+ : type === 'inline_math'
+ ? token.marker.length
+ : 1;
return markeredOffset(dis, len, markerLen, markerLen);
}
diff --git a/third_party/muya/src/clipboard/__tests__/pasteBlockMerge.spec.ts b/third_party/muya/src/clipboard/__tests__/pasteBlockMerge.spec.ts
index fbebd36..e8d7502 100644
--- a/third_party/muya/src/clipboard/__tests__/pasteBlockMerge.spec.ts
+++ b/third_party/muya/src/clipboard/__tests__/pasteBlockMerge.spec.ts
@@ -118,6 +118,17 @@ describe('paste — paragraph merges inline into a non-empty text block (A3)', (
});
});
+describe('paste — same-line display math (#4904)', () => {
+ it('renders pasted double-dollar math without rewriting its markdown', async () => {
+ const source = '$$ E=MC^2 $$';
+ const muya = bootMuya('');
+ const block = contentBlocks(muya)[0];
+
+ expect(await paste(muya, block, 0, 0, source)).toBe(`${source}\n`);
+ expect(muya.domNode.querySelector('.katex-display')).not.toBeNull();
+ });
+});
+
describe('paste — multi-line paragraph into a heading keeps only the first line (A6)', () => {
it('only the first soft-line lands in the heading, the rest become a paragraph', async () => {
const muya = bootMuya('# Title\n');
diff --git a/third_party/muya/src/config/index.ts b/third_party/muya/src/config/index.ts
index d9f88fa..3a9f758 100644
--- a/third_party/muya/src/config/index.ts
+++ b/third_party/muya/src/config/index.ts
@@ -154,6 +154,7 @@ export const CLASS_NAMES = genUpper2LowerKeyHash([
'MU_LINK_IN_BRACKET',
'MU_LIST_ITEM',
'MU_LOOSE_LIST_ITEM',
+ 'MU_DISPLAY_MATH',
'MU_MATH',
'MU_MATH_TEXT',
'MU_MATH_RENDER',
diff --git a/third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts b/third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts
new file mode 100644
index 0000000..5ba09fd
--- /dev/null
+++ b/third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts
@@ -0,0 +1,146 @@
+// @vitest-environment happy-dom
+
+import type { CodeEmojiMathToken } from '../types';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { Muya } from '../../muya';
+import { tokenizer } from '../lexer';
+import { execInlineDisplayMath } from '../rules';
+
+const bootedHosts: HTMLElement[] = [];
+
+beforeEach(() => {
+ window.MUYA_VERSION = 'test';
+});
+
+afterEach(() => {
+ while (bootedHosts.length)
+ bootedHosts.pop()!.remove();
+ document.getSelection()?.removeAllRanges();
+});
+
+function bootMuya(markdown: string): Muya {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const muya = new Muya(host, { markdown } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+ return muya;
+}
+
+function mathTokens(src: string): CodeEmojiMathToken[] {
+ return tokenizer(src).filter(
+ (token): token is CodeEmojiMathToken => token.type === 'inline_math',
+ );
+}
+
+describe('same-line display math (#4904)', () => {
+ it('tokenizes double-dollar formulas without requiring separate fence lines', () => {
+ const [token] = mathTokens('Before $$ E=MC^2 $$ after');
+
+ expect(token.marker).toBe('$$');
+ expect(token.content).toBe(' E=MC^2 ');
+ });
+
+ it('does not interpret an empty $$$$ delimiter pair as math', () => {
+ expect(mathTokens('$$$$')).toHaveLength(0);
+ });
+
+ it('allows a single dollar inside double-dollar math', () => {
+ const [token] = mathTokens('$$x$y$$');
+
+ expect(token.content).toBe('x$y');
+ });
+
+ it('tokenizes adjacent display formulas independently', () => {
+ const tokens = mathTokens('$$a$$$$b$$');
+
+ expect(tokens.map(token => [token.marker, token.content])).toEqual([
+ ['$$', 'a'],
+ ['$$', 'b'],
+ ]);
+ });
+
+ it('leaves multiline double-dollar syntax to the block parser', () => {
+ expect(mathTokens('$$a\nb$$')).toHaveLength(0);
+ });
+
+ it('falls back to single-dollar math when a double-dollar opener is unclosed', () => {
+ const [token] = mathTokens('$$x$');
+
+ expect(token.marker).toBe('$');
+ expect(token.content).toBe('x');
+ });
+
+ it('preserves soft line breaks inside single-dollar math', () => {
+ const [token] = mathTokens('$a\nb$');
+
+ expect(token.content).toBe('a\nb');
+ });
+
+ it('renders double-dollar formulas in display mode and single-dollar formulas inline', () => {
+ const muya = bootMuya('$x$ and $$x$$\n');
+ const previews = muya.domNode.querySelectorAll('.mu-math-render');
+ const wrappers = muya.domNode.querySelectorAll('.mu-math');
+
+ expect(previews).toHaveLength(2);
+ expect(previews[0].querySelector('.katex-display')).toBeNull();
+ expect(previews[1].querySelector('.katex-display')).not.toBeNull();
+ expect(wrappers[0].classList.contains('mu-display-math')).toBe(false);
+ expect(wrappers[1].classList.contains('mu-display-math')).toBe(true);
+ });
+
+ it('uses the full double-dollar marker when mapping preview offsets', () => {
+ const muya = bootMuya('$$x$$\n');
+ const preview = muya.domNode.querySelector('.mu-math-render');
+
+ expect(preview?.dataset.start).toBe('2');
+ expect(preview?.dataset.end).toBe('3');
+ });
+
+ it('preserves the authored same-line syntax when serializing markdown', () => {
+ const source = '$$ E=MC^2 $$\n';
+ const muya = bootMuya(source);
+
+ expect(muya.getMarkdown()).toBe(source);
+ });
+});
+
+describe('same-line display math — escape handling and adversarial input', () => {
+ it('lets an escaped closer extend the content to the next closer', () => {
+ const [token] = mathTokens('$$a\\$$b$$');
+
+ expect(token.marker).toBe('$$');
+ expect(token.content).toBe('a\\$$b');
+ });
+
+ it('treats an even backslash run before the closer as escaped-backslash content', () => {
+ const [token] = mathTokens('$$a\\\\$$');
+
+ expect(token.content).toBe('a\\\\');
+ });
+
+ it('does not match when the only closer is escaped', () => {
+ expect(execInlineDisplayMath('$$a\\$$')).toBeNull();
+ });
+
+ it('does not match a dangling backslash or a backslash before a newline', () => {
+ expect(execInlineDisplayMath('$$a\\')).toBeNull();
+ expect(execInlineDisplayMath('$$a\\\n$$')).toBeNull();
+ });
+
+ // The replaced regex backtracked quadratically here: 131,072 backslashes
+ // blocked Node for ~16 seconds. The scanner must stay linear.
+ it('handles adversarial backslash runs in linear time', () => {
+ const src = `$$${'\\'.repeat(131072)}$`;
+
+ const started = performance.now();
+ expect(execInlineDisplayMath(src)).toBeNull();
+ const tokens = mathTokens(src);
+ const elapsed = performance.now() - started;
+
+ // The full tokenizer may still match the tail as single-dollar math,
+ // but never as display math.
+ expect(tokens.every(token => token.marker === '$')).toBe(true);
+ expect(elapsed).toBeLessThan(500);
+ });
+});
diff --git a/third_party/muya/src/inlineRenderer/lexer.ts b/third_party/muya/src/inlineRenderer/lexer.ts
index 0b62368..06e6513 100644
--- a/third_party/muya/src/inlineRenderer/lexer.ts
+++ b/third_party/muya/src/inlineRenderer/lexer.ts
@@ -7,7 +7,7 @@ import type {
} from './types';
import escapeCharactersMap from '../config/escapeCharacter';
import { isLengthEven, union } from '../utils';
-import { beginRules, inlineRules, linkValidateRules, validateRules } from './rules';
+import { beginRules, execInlineDisplayMath, inlineRules, linkValidateRules, validateRules } from './rules';
import {
correctUrl,
getAttributes,
@@ -195,7 +195,15 @@ function tryChunks(state: ILexState): boolean {
const chunks = ['inline_code', 'del', 'emoji', 'inline_math'] as const;
for (const rule of chunks) {
- const to = state.inlineRules[rule].exec(state.src);
+ let to: RegExpExecArray | null;
+ if (rule === 'inline_math') {
+ to
+ = execInlineDisplayMath(state.src)
+ ?? state.inlineRules[rule].exec(state.src);
+ }
+ else {
+ to = state.inlineRules[rule].exec(state.src);
+ }
if (to && isLengthEven(to[3])) {
if (rule === 'emoji') {
// An emoji opener must sit at a word boundary: a ":" glued to a
diff --git a/third_party/muya/src/inlineRenderer/renderer/inlineMath.ts b/third_party/muya/src/inlineRenderer/renderer/inlineMath.ts
index bccf254..cacca08 100644
--- a/third_party/muya/src/inlineRenderer/renderer/inlineMath.ts
+++ b/third_party/muya/src/inlineRenderer/renderer/inlineMath.ts
@@ -16,13 +16,15 @@ export default function inlineMath(this: Renderer, {
}: ISyntaxRenderOptions & { token: CodeEmojiMathToken }) {
const className = this.getClassName(outerClass, block, token, cursor);
const { i18n } = this.muya;
- const mathSelector
+ const { start, end } = token.range;
+ const { marker } = token;
+ const displayMode = marker.length === 2;
+ let mathSelector
= className === CLASS_NAMES.MU_HIDE
? `span.${className}.${CLASS_NAMES.MU_MATH}`
: `span.${CLASS_NAMES.MU_MATH}`;
-
- const { start, end } = token.range;
- const { marker } = token;
+ if (displayMode)
+ mathSelector += `.${CLASS_NAMES.MU_DISPLAY_MATH}`;
const startMarker = this.highlight(
h,
@@ -44,12 +46,10 @@ export default function inlineMath(this: Renderer, {
const { loadMathMap } = this;
- const displayMode = false;
- const key = `${math}_${type}`;
+ const key = JSON.stringify([math, type, displayMode]);
let mathVnode = null;
let previewSelector = `span.${CLASS_NAMES.MU_MATH_RENDER}`;
- // Inline math errors stay compact to keep the surrounding text baseline
- // (#4100, inline-math-align); surface the parse reason via the title.
+ // Single-dollar errors stay compact to preserve the surrounding text baseline (#4100).
let errorTitle = '';
if (loadMathMap.has(key)) {
mathVnode = loadMathMap.get(key);
@@ -86,8 +86,8 @@ export default function inlineMath(this: Renderer, {
? { contenteditable: 'false', title: errorTitle }
: { contenteditable: 'false' },
dataset: {
- start: String(start + 1), // '$'.length
- end: String(end - 1), // '$'.length
+ start: String(start + marker.length),
+ end: String(end - marker.length),
},
},
mathVnode,
diff --git a/third_party/muya/src/inlineRenderer/rules.ts b/third_party/muya/src/inlineRenderer/rules.ts
index eee16b1..57728a2 100644
--- a/third_party/muya/src/inlineRenderer/rules.ts
+++ b/third_party/muya/src/inlineRenderer/rules.ts
@@ -65,6 +65,55 @@ export const gfmRules = {
export type GfmRules = typeof gfmRules;
+/**
+ * Single-line `$$...$$` display math. Semantically this is
+ * `/^(\$\$)(?!\$)((?:(?!\1)[^\\\n]|\\.)+)(\\*)\1/`, but that regex backtracks
+ * quadratically on `$$` followed by a long backslash run (the `\\.` content
+ * pairs overlap the `(\\*)` trailing group), which can freeze tokenization
+ * for seconds on a crafted line. This scanner is the linear-time equivalent:
+ * content ends at the first unescaped same-line `$$`, a backslash always
+ * escapes the following character (the regex's greedy pair-first behavior,
+ * so an escaped `$` extends the content past that closer), and a returned
+ * match therefore always has an unescaped closer — the escape-parity group
+ * (`to[3]`) is always empty and passes the lexer's `isLengthEven` check.
+ */
+export function execInlineDisplayMath(src: string): RegExpExecArray | null {
+ if (!src.startsWith('$$') || src[2] === '$')
+ return null;
+
+ const { length } = src;
+ let i = 2;
+ while (i < length) {
+ const char = src[i];
+ if (char === '\n')
+ return null;
+
+ if (char === '$' && src[i + 1] === '$') {
+ const content = src.slice(2, i);
+ const match = [
+ `$$${content}$$`,
+ '$$',
+ content,
+ '',
+ ] as string[] as RegExpExecArray;
+ match.index = 0;
+ match.input = src;
+ return match;
+ }
+
+ if (char === '\\') {
+ if (i + 1 >= length || src[i + 1] === '\n')
+ return null;
+ i += 2;
+ }
+ else {
+ i += 1;
+ }
+ }
+
+ return null;
+}
+
// Markdown extensions (not belongs to GFM and Commonmark)
export const inlineExtensionRules = {
// eslint-disable-next-line regexp/no-super-linear-backtracking