From b01d3489439641f2ffb5c52c20cab29a1ec343a6 Mon Sep 17 00:00:00 2001 From: SecPan Date: Wed, 15 Jul 2026 19:17:03 -0700 Subject: [PATCH 1/2] Port upstream 4935: render same-line double-dollar display math --- tests/e2e/mobile-display-math.spec.ts | 50 +++++++++ .../muya/src/assets/styles/inlineSyntax.css | 7 ++ .../block/base/__tests__/formatToggle.spec.ts | 10 ++ third_party/muya/src/block/base/format.ts | 7 +- .../__tests__/pasteBlockMerge.spec.ts | 11 ++ third_party/muya/src/config/index.ts | 1 + .../__tests__/sameLineDisplayMath.spec.ts | 105 ++++++++++++++++++ third_party/muya/src/inlineRenderer/lexer.ts | 12 +- .../src/inlineRenderer/renderer/inlineMath.ts | 20 ++-- third_party/muya/src/inlineRenderer/rules.ts | 3 + 10 files changed, 213 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/mobile-display-math.spec.ts create mode 100644 third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts 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 afe0f4c..7967bdc 100644 --- a/third_party/muya/src/block/base/format.ts +++ b/third_party/muya/src/block/base/format.ts @@ -111,7 +111,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..705b7ec --- /dev/null +++ b/third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts @@ -0,0 +1,105 @@ +// @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'; + +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); + }); +}); diff --git a/third_party/muya/src/inlineRenderer/lexer.ts b/third_party/muya/src/inlineRenderer/lexer.ts index 0b62368..487c0af 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, inlineDisplayMathRule, 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 + = inlineDisplayMathRule.exec(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..1af2da2 100644 --- a/third_party/muya/src/inlineRenderer/rules.ts +++ b/third_party/muya/src/inlineRenderer/rules.ts @@ -65,6 +65,9 @@ export const gfmRules = { export type GfmRules = typeof gfmRules; +// eslint-disable-next-line regexp/no-super-linear-backtracking +export const inlineDisplayMathRule = /^(\$\$)(?!\$)((?:(?!\1)[^\\\n]|\\.)+)(\\*)\1/; + // Markdown extensions (not belongs to GFM and Commonmark) export const inlineExtensionRules = { // eslint-disable-next-line regexp/no-super-linear-backtracking From 92c86bb753b9e21a6b3ed6a9c2079ccae1f99a23 Mon Sep 17 00:00:00 2001 From: SecPan Date: Wed, 15 Jul 2026 20:05:27 -0700 Subject: [PATCH 2/2] Replace the display-math regex with a linear-time scanner --- .../__tests__/sameLineDisplayMath.spec.ts | 41 +++++++++++++++ third_party/muya/src/inlineRenderer/lexer.ts | 4 +- third_party/muya/src/inlineRenderer/rules.ts | 50 ++++++++++++++++++- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts b/third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts index 705b7ec..5ba09fd 100644 --- a/third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts +++ b/third_party/muya/src/inlineRenderer/__tests__/sameLineDisplayMath.spec.ts @@ -4,6 +4,7 @@ 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[] = []; @@ -103,3 +104,43 @@ describe('same-line display math (#4904)', () => { 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 487c0af..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, inlineDisplayMathRule, inlineRules, linkValidateRules, validateRules } from './rules'; +import { beginRules, execInlineDisplayMath, inlineRules, linkValidateRules, validateRules } from './rules'; import { correctUrl, getAttributes, @@ -198,7 +198,7 @@ function tryChunks(state: ILexState): boolean { let to: RegExpExecArray | null; if (rule === 'inline_math') { to - = inlineDisplayMathRule.exec(state.src) + = execInlineDisplayMath(state.src) ?? state.inlineRules[rule].exec(state.src); } else { diff --git a/third_party/muya/src/inlineRenderer/rules.ts b/third_party/muya/src/inlineRenderer/rules.ts index 1af2da2..57728a2 100644 --- a/third_party/muya/src/inlineRenderer/rules.ts +++ b/third_party/muya/src/inlineRenderer/rules.ts @@ -65,8 +65,54 @@ export const gfmRules = { export type GfmRules = typeof gfmRules; -// eslint-disable-next-line regexp/no-super-linear-backtracking -export const inlineDisplayMathRule = /^(\$\$)(?!\$)((?:(?!\1)[^\\\n]|\\.)+)(\\*)\1/; +/** + * 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 = {