From 8cd2157564c6e8a531e6d2f32c4a34ac13a3cd83 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:02:00 +0100 Subject: [PATCH] Perf: precompile katex unicode regex (#22196) * perf: pre-compile KaTeX Unicode regex at module load time The katexStart() function was creating a new RegExp with Unicode property escapes (\p{Script=Han}, \p{Script=Hiragana}, etc.) on every invocation. Unicode property escapes are extremely expensive to compile as the regex engine must build character class tables covering tens of thousands of code points. Since marked calls the start() function at every character position while scanning source text, this meant hundreds of regex compilations per marked.lexer() call, and lexer runs ~60 times/sec during streaming. Profiling showed KaTeX regex consuming 87% (320ms/365ms) of total markdown rendering time. Changes: - Pre-compile SURROUNDING_CHARS_REGEX once at module load time - Use .test() instead of .match() to avoid array allocations - Fix delimiter search to find earliest match, not last match * perf: replace katexStart with single-pass character scan The katexStart() function was the dominant cost in marked's lexer, consuming 55-58% of total markdown rendering time per profiling. It was called at every character position by marked and each call: - Looped through 3-5 delimiters, each doing indexOf() on the full remaining source (3-5 x O(n) string scans per call) - Ran the complex ruleReg regex with Unicode lookaheads for validation - On failed validation, created substrings and looped again Replace with a single linear character scan using charCodeAt that: - Checks only for $ (charCode 36) or backslash (charCode 92) - Filters backslash hits by next character to avoid false positives - Preserves the surrounding-character validation - Returns immediately on first valid candidate - Lets the tokenizer handle full validation (it already does this) This reduces start() from O(n * delimiters * retries) to O(n) with a very small constant factor per call. * Update katex-extension.ts --- src/lib/utils/marked/katex-extension.ts | 61 ++++++++++--------------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/src/lib/utils/marked/katex-extension.ts b/src/lib/utils/marked/katex-extension.ts index dd755066ce..13860fab30 100644 --- a/src/lib/utils/marked/katex-extension.ts +++ b/src/lib/utils/marked/katex-extension.ts @@ -13,6 +13,12 @@ const ALLOWED_SURROUNDING_CHARS = '\\s。,、、;;„“‘’“”()「」『』[]《》【】‹›«»…⋯::?!~⇒?!-\\/:-@\\[-`{-~\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}'; // Modified to fit more formats in different languages. Originally: '\\s?。,、;!-\\/:-@\\[-`{-~\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}'; +// Pre-compile the surrounding character regex once at module load time. +// This regex uses Unicode property escapes (\p{Script=Han}, etc.) which are +// extremely expensive to compile - doing so on every call caused ~87% of +// markdown rendering time to be spent in KaTeX regex compilation. +const ALLOWED_SURROUNDING_CHARS_REGEX = new RegExp(`[${ALLOWED_SURROUNDING_CHARS}]`, 'u'); + // const DELIMITER_LIST = [ // { left: '$$', right: '$$', display: false }, // { left: '$', right: '$', display: false }, @@ -67,48 +73,31 @@ export default function (options = {}) { } function katexStart(src, displayMode: boolean) { - const ruleReg = displayMode ? blockRule : inlineRule; + for (let i = 0; i < src.length; i++) { + const ch = src.charCodeAt(i); - let indexSrc = src; - - while (indexSrc) { - let index = -1; - let startIndex = -1; - let startDelimiter = ''; - let endDelimiter = ''; - for (const delimiter of DELIMITER_LIST) { - if (delimiter.display !== displayMode) { + if (ch === 36 /* $ */) { + // Display mode requires $$, skip single $ for display + if (displayMode && src.charAt(i + 1) !== '$') { continue; } - - startIndex = indexSrc.indexOf(delimiter.left); - if (startIndex === -1) { - continue; + if (i === 0 || ALLOWED_SURROUNDING_CHARS_REGEX.test(src.charAt(i - 1))) { + return i; } - - index = startIndex; - startDelimiter = delimiter.left; - endDelimiter = delimiter.right; - } - - if (index === -1) { - return; - } - - // Check if the delimiter is preceded by a special character. - // If it does, then it's potentially a math formula. - const f = - index === 0 || - indexSrc.charAt(index - 1).match(new RegExp(`[${ALLOWED_SURROUNDING_CHARS}]`, 'u')); - if (f) { - const possibleKatex = indexSrc.substring(index); - - if (possibleKatex.match(ruleReg)) { - return index; + } else if (ch === 92 /* \ */) { + const next = src.charAt(i + 1); + // Only consider \ if followed by a valid math delimiter start + if (displayMode) { + // Display: \[ or \begin{equation} + if (next !== '[' && next !== 'b') continue; + } else { + // Inline: \( or \ce{ or \pu{ + if (next !== '(' && next !== 'c' && next !== 'p') continue; + } + if (i === 0 || ALLOWED_SURROUNDING_CHARS_REGEX.test(src.charAt(i - 1))) { + return i; } } - - indexSrc = indexSrc.substring(index + startDelimiter.length).replace(endDelimiter, ''); } }