Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

311 lines
9.6 KiB
TypeScript

import katex from 'katex';
export type QuizMathTextSegment =
| {
type: 'text';
value: string;
}
| {
type: 'math';
value: string;
html: string;
displayMode: boolean;
};
interface Delimiter {
open: string;
close: string;
displayMode: boolean;
}
const DELIMITERS: Delimiter[] = [
{ open: '$$', close: '$$', displayMode: true },
{ open: '\\[', close: '\\]', displayMode: true },
{ open: '\\(', close: '\\)', displayMode: false },
{ open: '$', close: '$', displayMode: false },
];
const LATEX_COMMAND_RE = /\\[a-zA-Z]+/;
const LATEX_COMMAND_GLOBAL_RE = /\\[a-zA-Z]+/g;
const INLINE_OPERATOR_RE = /[A-Za-z0-9)\]}]\s*[+\-*/]\s*[A-Za-z0-9({\\]/;
const FORMULA_CHAR_RE = /^[\s0-9A-Za-z\\{}()[\]^_+\-*/=<>≤≥≈.,:;|!%√πθαβγδελμνρσφωΑΒΓΔΘΛΜΝΠΡΣΦΩ]+$/;
const WORD_RE = /[A-Za-z]{3,}/g;
const EXPLICIT_DELIMITER_RE = /\\\[|\\\(|(?:^|[^\\])\$\$?/;
const LETTER_OR_COMMAND_RE = /[A-Za-z\\πθαβγδελμνρσφωΑΒΓΔΘΛΜΝΠΡΣΦΩ]/;
const EQUATION_OR_POWER_RE = /[=<>≤≥≈^_]/;
const SINGLE_SYMBOL_RE = /^(?:[A-Za-z]|\\[a-zA-Z]+|\d+(?:\.\d+)?)$/;
const CODE_LIKE_BOOLEAN_ASSIGNMENT_RE =
/^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:true|false|null|undefined|none)$/i;
const CODE_LIKE_INCREMENT_RE = /^([ijk])\s*=\s*\1\s*[+-]\s*1$/i;
const SENTENCE_BOUNDARY_CHAR_RE = /^[.,!?;:]$/;
const TOKEN_RE = /\S+/g;
export function isLikelyStandaloneMathText(value: string): boolean {
const text = value.trim();
if (text.length < 3) return false;
if (!FORMULA_CHAR_RE.test(text)) return false;
if (!LETTER_OR_COMMAND_RE.test(text) && !EQUATION_OR_POWER_RE.test(text)) return false;
if (!LATEX_COMMAND_RE.test(text) && !EQUATION_OR_POWER_RE.test(text)) return false;
const proseWords = text.replace(LATEX_COMMAND_GLOBAL_RE, ' ').match(WORD_RE) ?? [];
if (proseWords.length > 0) return false;
if (isCodeLikeAssignment(text)) return false;
return true;
}
export function renderLatexToHtml(value: string, displayMode = false): string | null {
try {
return katex.renderToString(escapeLiteralPercents(value), {
displayMode,
output: 'html',
strict: false,
throwOnError: true,
});
} catch {
return null;
}
}
function escapeLiteralPercents(value: string): string {
let escaped = '';
for (let index = 0; index < value.length; index += 1) {
if (value[index] !== '%') {
escaped += value[index];
continue;
}
escaped += isEscaped(value, index) ? '%' : '\\%';
}
return escaped;
}
export function parseQuizMathText(value: string): QuizMathTextSegment[] {
const segments: QuizMathTextSegment[] = [];
let cursor = 0;
while (cursor < value.length) {
const opening = findNextDelimiter(value, cursor);
if (!opening) break;
if (opening.index > cursor) {
segments.push({ type: 'text', value: value.slice(cursor, opening.index) });
}
const mathStart = opening.index + opening.delimiter.open.length;
const closeIndex = findClosingDelimiter(value, opening.delimiter.close, mathStart);
if (closeIndex === -1) {
segments.push({ type: 'text', value: value.slice(opening.index) });
cursor = value.length;
break;
}
const latex = value.slice(mathStart, closeIndex).trim();
if (latex) {
const shouldRender = opening.delimiter.open !== '$' || isLikelyDelimitedMathText(latex);
const html = shouldRender ? renderLatexToHtml(latex, opening.delimiter.displayMode) : null;
if (html) {
segments.push({
type: 'math',
value: latex,
html,
displayMode: opening.delimiter.displayMode,
});
} else {
segments.push({
type: 'text',
value: value.slice(opening.index, closeIndex + opening.delimiter.close.length),
});
}
} else {
segments.push({
type: 'text',
value: value.slice(opening.index, closeIndex + opening.delimiter.close.length),
});
}
cursor = closeIndex + opening.delimiter.close.length;
}
if (cursor < value.length) {
segments.push({ type: 'text', value: value.slice(cursor) });
}
return mergeTextSegments(segments);
}
function parseEmbeddedMathText(value: string): QuizMathTextSegment[] | null {
const segments: QuizMathTextSegment[] = [];
let cursor = 0;
let hasMath = false;
TOKEN_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = TOKEN_RE.exec(value)) !== null) {
const tokenStart = match.index;
const tokenEnd = tokenStart + match[0].length;
const candidate = trimEmbeddedMathCandidate(value, tokenStart, tokenEnd);
if (!candidate || candidate.start < cursor) continue;
if (!isLikelyEmbeddedMathText(candidate.value)) continue;
const html = renderLatexToHtml(candidate.value, false);
if (!html) continue;
if (candidate.start > cursor) {
segments.push({ type: 'text', value: value.slice(cursor, candidate.start) });
}
segments.push({
type: 'math',
value: candidate.value,
html,
displayMode: false,
});
hasMath = true;
cursor = candidate.end;
}
if (!hasMath) return null;
if (cursor < value.length) {
segments.push({ type: 'text', value: value.slice(cursor) });
}
return mergeTextSegments(segments);
}
function trimEmbeddedMathCandidate(
value: string,
start: number,
end: number,
): { start: number; end: number; value: string } | null {
let candidateStart = start;
let candidateEnd = end;
while (candidateStart < candidateEnd && /["'“”‘’]/.test(value[candidateStart])) {
candidateStart += 1;
}
while (candidateEnd > candidateStart && SENTENCE_BOUNDARY_CHAR_RE.test(value[candidateEnd - 1])) {
candidateEnd -= 1;
}
const candidate = value.slice(candidateStart, candidateEnd);
if (!candidate) return null;
return { start: candidateStart, end: candidateEnd, value: candidate };
}
function isLikelyEmbeddedMathText(value: string): boolean {
const text = value.trim();
if (text.length < 6) return false;
if (!FORMULA_CHAR_RE.test(text)) return false;
if (!LETTER_OR_COMMAND_RE.test(text)) return false;
if (isCodeLikeAssignment(text)) return false;
const proseWords = text.replace(LATEX_COMMAND_GLOBAL_RE, ' ').match(WORD_RE) ?? [];
if (proseWords.length > 0) return false;
if (!EQUATION_OR_POWER_RE.test(text)) return false;
return INLINE_OPERATOR_RE.test(text) || LATEX_COMMAND_RE.test(text) || text.length >= 12;
}
function isLikelyDelimitedMathText(value: string): boolean {
const text = value.trim();
if (!text) return false;
if (!FORMULA_CHAR_RE.test(text)) return false;
if (SINGLE_SYMBOL_RE.test(text)) return true;
if (LATEX_COMMAND_RE.test(text) || EQUATION_OR_POWER_RE.test(text)) return true;
if (INLINE_OPERATOR_RE.test(text) && (text.match(WORD_RE) ?? []).length <= 1) return true;
return false;
}
export function renderQuizMathText(value: string): QuizMathTextSegment[] {
const likelyStandalone = isLikelyStandaloneMathText(value);
const hasExplicitDelimiter = EXPLICIT_DELIMITER_RE.test(value);
if (!hasExplicitDelimiter && !likelyStandalone) {
return parseEmbeddedMathText(value) ?? [{ type: 'text', value }];
}
const delimited = parseQuizMathText(value);
if (delimited.some((segment) => segment.type === 'math')) return delimited;
if (!likelyStandalone) return [{ type: 'text', value }];
const latex = value.trim();
const html = renderLatexToHtml(latex, false);
if (!html) return [{ type: 'text', value }];
const prefix = value.slice(0, value.indexOf(latex));
const suffix = value.slice(value.indexOf(latex) + latex.length);
return mergeTextSegments([
...(prefix ? ([{ type: 'text', value: prefix }] as QuizMathTextSegment[]) : []),
{ type: 'math', value: latex, html, displayMode: false },
...(suffix ? ([{ type: 'text', value: suffix }] as QuizMathTextSegment[]) : []),
]);
}
function findNextDelimiter(
value: string,
startIndex: number,
): { delimiter: Delimiter; index: number } | null {
let match: { delimiter: Delimiter; index: number } | null = null;
for (const delimiter of DELIMITERS) {
const index = findUnescaped(value, delimiter.open, startIndex);
if (index === -1) continue;
if (
!match ||
index < match.index ||
(index === match.index && delimiter.open.length > match.delimiter.open.length)
) {
match = { delimiter, index };
}
}
return match;
}
function findClosingDelimiter(value: string, delimiter: string, startIndex: number): number {
return findUnescaped(value, delimiter, startIndex);
}
function findUnescaped(value: string, search: string, startIndex: number): number {
let index = value.indexOf(search, startIndex);
while (index !== -1) {
if (!isEscaped(value, index)) return index;
index = value.indexOf(search, index + search.length);
}
return -1;
}
function isEscaped(value: string, index: number): boolean {
let slashCount = 0;
for (let i = index - 1; i >= 0 && value[i] === '\\'; i -= 1) {
slashCount += 1;
}
return slashCount % 2 === 1;
}
function isCodeLikeAssignment(value: string): boolean {
const text = value.trim();
return CODE_LIKE_BOOLEAN_ASSIGNMENT_RE.test(text) || CODE_LIKE_INCREMENT_RE.test(text);
}
function mergeTextSegments(segments: QuizMathTextSegment[]): QuizMathTextSegment[] {
const merged: QuizMathTextSegment[] = [];
for (const segment of segments) {
const last = merged[merged.length - 1];
if (segment.type === 'text' && last?.type === 'text') {
last.value += segment.value;
} else if (segment.type === 'text' && segment.value === '') {
continue;
} else {
merged.push(segment);
}
}
return merged.length > 0 ? merged : [{ type: 'text', value: '' }];
}