// js/processing/markdown_processor_ast.js
// AST-based Markdown processor using markdown-it
// 全新架构:基于抽象语法树的 Markdown 处理器
(function MarkdownProcessorAST(global) {
'use strict';
// ========================================
// 核心配置
// ========================================
const CONFIG = {
version: '3.0.0-ast',
cacheSize: 1000,
debug: false
};
// 缓存系统
const renderCache = new Map();
// Phase 3.5: 记录已经警告过的图片路径,避免流式更新时重复警告
const _warnedImages = new Set();
// 性能指标
const metrics = {
cacheHits: 0,
cacheMisses: 0,
totalRenders: 0,
formulaErrors: 0,
formulaSuccesses: 0,
tableFixCount: 0
};
// ========================================
// Markdown-it 初始化
// ========================================
if (typeof markdownit === 'undefined') {
console.error('[MarkdownProcessorAST] markdown-it not loaded!');
return;
}
const md = markdownit({
html: true, // 允许 HTML 标签
breaks: false, // 不自动转换换行(避免破坏表格)
linkify: false, // 不自动转换链接
typographer: false // 不进行印刷优化(避免干扰公式)
});
// ========================================
// 工具函数
// ========================================
/**
* HTML 转义
*/
function escapeHtml(text) {
if (typeof text !== 'string') return '';
const htmlEscapes = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, (match) => htmlEscapes[match]);
}
/**
* 检测内容是否像段落(而非单个公式)
*/
function looksLikeParagraph(text) {
if (!text || typeof text !== 'string') return false;
// 白名单:包含明显的 LaTeX 命令,应该被识别为公式
if (/\\(mathrm|mathbf|mathit|text|frac|sqrt|sum|int|limits|cdot|cdots|ldots|dots|times|div|pm|infty|alpha|beta|gamma|delta|epsilon|theta|lambda|mu|sigma|omega|mathbb|psi|rangle|langle|in)\b/.test(text)) {
return false; // 不是段落,是公式
}
// 白名单:包含常见的 LaTeX 空格命令
if (/\\[,;:!\s]/.test(text)) {
return false; // 不是段落,是公式
}
// 白名单:包含数学符号(下标、上标、括号等),应该被识别为公式
if (/[_^{}=+\-*/()]/.test(text)) {
return false; // 包含数学符号,是公式
}
// 先移除 LaTeX 转义序列(如 \; \, \! 等),避免误判
const cleanText = text.replace(/\\[,;:!]/g, '');
// 包含句子标点
if (/[。;;]/.test(cleanText)) return true;
// 包含多个逗号(提高阈值到 10 个,因为数学公式中逗号很常见)
if ((cleanText.match(/[,,]/g) || []).length > 10) return true;
// 包含英文解释性词汇
if (/\b(represents?|where|is|are|and|the|of)\b/i.test(text)) return true;
return false;
}
/**
* 记录调试信息
*/
function debug(...args) {
if (CONFIG.debug) {
console.log('[MarkdownProcessorAST]', ...args);
}
}
// ========================================
// 插件 1: OCR 错误修复(Token 级别)
// ========================================
function ocrFixPlugin(md) {
debug('Loading OCR fix plugin');
// 在 inline 解析之前修复文本
md.core.ruler.before('inline', 'ocr_fix', function(state) {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
// 只处理段落、表格单元格等文本容器
if (token.type === 'inline' && token.content) {
token.content = normalizeMathDelimiters(token.content);
}
}
});
/**
* 修复 OCR 错误的数学分隔符
*/
function normalizeMathDelimiters(text) {
if (typeof text !== 'string' || !text) return text;
let s = text;
// 基础清理
s = s.replace(/&(?:#0*36|dollar);/gi, '$');
s = s.replace(/\uFF04/g, '$');
s = s.replace(/\$[\u200B-\u200D\uFEFF\u0300-\u036F]+/g, '$');
s = s.replace(/[\u200B-\u200D\uFEFF\u0300-\u036F]+\$/g, '$');
// OCR 错误修复(带防护)
// 1. $\$ ... \$ ,$ → $$ ... $$ ,
s = s.replace(/\$\\\$\s*([^\$\n]{1,200}?)\s*\\\$\s*,\s*\$/g, (match, content) => {
if (looksLikeParagraph(content)) return match;
return `$$${content}$$ ,`;
});
// 2. $\$ ... \$$ → $$ ... $$
s = s.replace(/\$\\\$\s*([^\$\n]{1,200}?)\s*\\\$\$/g, (match, content) => {
if (looksLikeParagraph(content)) return match;
return `$$${content}$$`;
});
// 3. $\$ ... \$ → $$ ... $$
s = s.replace(/\$\\\$\s*([^\$\n]{1,200}?)\s*\\\$/g, (match, content) => {
if (looksLikeParagraph(content)) return match;
return `$$${content}$$`;
});
// 4. \$...\$ → $$...$$
s = s.replace(/\\\$([^\$\n]+?)\\\$/g, '$$$$1$$');
return s;
}
}
// ========================================
// 插件 2: 表格修复(AST 级别)
// ========================================
function tableFixPlugin(md) {
debug('Loading table fix plugin');
md.core.ruler.after('inline', 'table_fix', function(state) {
const tokens = state.tokens;
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
// 找到表格开始
if (token.type === 'table_open') {
const tableTokens = [];
let j = i;
// 收集整个表格的 tokens
while (j < tokens.length && tokens[j].type !== 'table_close') {
tableTokens.push(tokens[j]);
j++;
}
if (j < tokens.length) {
tableTokens.push(tokens[j]); // table_close
}
// 尝试修复表格
const fixed = fixTableStructure(tableTokens);
if (fixed) {
// 替换原始 tokens
tokens.splice(i, j - i + 1, ...fixed);
metrics.tableFixCount++;
debug('Fixed table at token', i);
}
i = j + 1;
} else {
i++;
}
}
});
/**
* 修复表格结构
* 主要处理:列数不一致、空单元格开头的行(可能需要合并到上一行)
*/
function fixTableStructure(tokens) {
// 分析表格结构
const rows = [];
let currentRow = null;
let columnCount = 0;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === 'tr_open') {
currentRow = { tokens: [token], cells: [] };
} else if (token.type === 'tr_close') {
if (currentRow) {
currentRow.tokens.push(token);
rows.push(currentRow);
// 记录最大列数(从头部行)
if (rows.length === 1) {
columnCount = currentRow.cells.length;
}
currentRow = null;
}
} else if (token.type === 'th_open' || token.type === 'td_open') {
const cell = { open: token, content: null, close: null };
if (currentRow) {
currentRow.cells.push(cell);
currentRow.tokens.push(token);
}
} else if (token.type === 'inline') {
if (currentRow && currentRow.cells.length > 0) {
const lastCell = currentRow.cells[currentRow.cells.length - 1];
lastCell.content = token;
currentRow.tokens.push(token);
}
} else if (token.type === 'th_close' || token.type === 'td_close') {
if (currentRow && currentRow.cells.length > 0) {
const lastCell = currentRow.cells[currentRow.cells.length - 1];
lastCell.close = token;
currentRow.tokens.push(token);
}
} else {
if (currentRow) {
currentRow.tokens.push(token);
}
}
}
// 检测并修复问题行
let needsFix = false;
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
const prevRow = rows[i - 1];
// 情况1:当前行列数不足,且第一个单元格为空
if (row.cells.length < columnCount &&
row.cells[0].content &&
!row.cells[0].content.content.trim()) {
needsFix = true;
debug('Table row', i, 'needs merge (empty first cell)');
}
// 情况2:当前行以括号开头(可能是统计量)
if (row.cells.length > 0 &&
row.cells[0].content &&
/^\s*\(/.test(row.cells[0].content.content)) {
needsFix = true;
debug('Table row', i, 'needs merge (starts with parenthesis)');
}
}
// 如果不需要修复,返回 null
if (!needsFix) {
return null;
}
// TODO: 实际合并逻辑(复杂,暂时返回原始 tokens)
// 这里可以进一步实现行合并、单元格填充等
debug('Table fix logic not yet implemented, returning original');
return null;
}
}
// ========================================
// 插件 3: 公式处理(替换为 KaTeX 渲染)
// ========================================
function mathPlugin(md) {
debug('Loading math plugin');
// 处理行内公式 $...$ 和 $$...$$
md.inline.ruler.before('escape', 'math_inline', function(state, silent) {
const start = state.pos;
const max = state.posMax;
// 必须以 $ 开头
if (state.src.charCodeAt(start) !== 0x24 /* $ */) {
return false;
}
// 检测是否是 $$(块级公式在行内)
const isDouble = (start + 1 < max && state.src.charCodeAt(start + 1) === 0x24);
const searchStart = isDouble ? start + 2 : start + 1;
const endMarker = isDouble ? '$$' : '$';
// 寻找结束标记
let pos = searchStart;
let foundEnd = false;
while (pos < max) {
const char = state.src.charCodeAt(pos);
// 遇到换行符,停止搜索(行内公式不应跨行)
if (char === 0x0A /* \n */) {
break;
}
// 遇到反斜杠,跳过反斜杠和后面的字符
if (char === 0x5C /* \ */) {
pos += 2;
continue;
}
// 找到 $
if (char === 0x24 /* $ */) {
if (isDouble) {
// 需要确认是 $$
if (pos + 1 < max && state.src.charCodeAt(pos + 1) === 0x24) {
foundEnd = true;
break; // 找到 $$
}
} else {
foundEnd = true;
break; // 找到 $
}
}
pos++;
}
if (!foundEnd) {
return false; // 没有找到闭合标记
}
const content = state.src.slice(searchStart, pos);
// 内容不能为空
if (!content || !content.trim()) {
return false;
}
// 快速检查:跳过纯中文(但允许单个汉字数学公式)
if (content.length > 1 && /^[\u4e00-\u9fa5,、。;:!?""''()【】《》\s]+$/.test(content)) {
return false;
}
// 检查是否像段落(只对单 $ 检查,且长度超过3个字符)
if (!isDouble && content.length > 3 && looksLikeParagraph(content)) {
return false;
}
if (!silent) {
// 在段落中的 $$...$$ 也使用 inline mode(不独立成行)
const token = state.push('math_inline', 'math', 0);
token.content = content.trim();
token.markup = endMarker;
token.block = false; // 行内元素统一使用 inline mode
}
state.pos = pos + (isDouble ? 2 : 1);
return true;
});
// 处理块级公式 $$...$$
md.block.ruler.before('fence', 'math_block', function(state, startLine, endLine, silent) {
let pos = state.bMarks[startLine] + state.tShift[startLine];
let max = state.eMarks[startLine];
// 检查是否以 $$ 开头
if (pos + 2 > max) return false;
if (state.src.charCodeAt(pos) !== 0x24 || state.src.charCodeAt(pos + 1) !== 0x24) {
return false;
}
pos += 2;
let firstLine = state.src.slice(pos, max);
// 单行块公式: $$...$$ 在同一行
if (firstLine.trim().slice(-2) === '$$') {
firstLine = firstLine.trim().slice(0, -2);
if (!silent) {
const token = state.push('math_block', 'math', 0);
token.content = firstLine;
token.markup = '$$';
token.block = true;
token.map = [startLine, startLine + 1];
}
state.line = startLine + 1;
return true;
}
// 多行块公式
let nextLine = startLine;
let lastLine;
let lastPos;
while (nextLine < endLine) {
nextLine++;
if (nextLine >= endLine) break;
pos = state.bMarks[nextLine] + state.tShift[nextLine];
max = state.eMarks[nextLine];
if (pos < max && state.sCount[nextLine] < state.blkIndent) {
break;
}
// 检查是否以 $$ 结尾
if (state.src.slice(pos, max).trim().slice(-2) === '$$') {
lastPos = state.src.slice(0, max).lastIndexOf('$$');
lastLine = state.src.slice(pos, lastPos);
break;
}
}
if (!lastPos && lastPos !== 0) {
return false;
}
if (!silent) {
const oldParent = state.parentType;
const oldLineMax = state.lineMax;
state.parentType = 'math';
const content = state.getLines(startLine + 1, nextLine, state.tShift[startLine], true);
const token = state.push('math_block', 'math', 0);
token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') + content;
token.markup = '$$';
token.block = true;
token.map = [startLine, nextLine + 1];
state.parentType = oldParent;
state.lineMax = oldLineMax;
}
state.line = nextLine + 1;
return true;
});
// 渲染规则
md.renderer.rules.math_inline = function(tokens, idx) {
const content = tokens[idx].content;
try {
const rendered = katex.renderToString(content, {
displayMode: false,
throwOnError: true,
strict: 'ignore'
});
metrics.formulaSuccesses++;
return `${rendered}`;
} catch (error) {
metrics.formulaErrors++;
console.warn('[MarkdownProcessorAST] KaTeX inline error:', error.message);
return `${escapeHtml(content)}`;
}
};
md.renderer.rules.math_block = function(tokens, idx) {
const content = tokens[idx].content;
try {
const rendered = katex.renderToString(content, {
displayMode: true,
throwOnError: true,
strict: 'ignore'
});
metrics.formulaSuccesses++;
return `
${escapeHtml(content)}