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
This commit is contained in:
Classic298
2026-03-05 23:02:00 +01:00
committed by GitHub
parent aaa49bdd6d
commit 8cd2157564
+25 -36
View File
@@ -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, '');
}
}