// process/tables.js /** * 在翻译前对Markdown表格进行特殊处理,将其替换为占位符,以确保表格结构在翻译过程中保持完整性。 * * 主要步骤: * 1. **预处理**: * - 标准化换行符为 `\n`。 * - 移除每行表格前可能存在的影响识别的行首空格或制表符。 * 2. **表格边界检测**: * - 使用更可靠的方法检测表格:寻找连续的表格行(包括表头、分隔行和数据行)。 * - 定义表格行 (`tableRowRegex`) 和表格分隔行 (`tableSepRegex`) 的正则表达式。 * - 扫描文本行,识别潜在的表格标题(以 "TABLE", "Table", "表" 开头且下一行是表格行的行)。 * 3. **表格范围确定**: * - 遍历文本行,标记表格的开始和结束行号,存入 `tableRanges`。 * - 考虑最小有效表格行数 (`minTableRows`),避免将非表格内容误认为表格。 * - 如果表格前有识别到的标题,则将标题行也包含在表格范围内。 * - 特殊处理文档末尾的表格。 * 4. **合并相邻表格**: * - 遍历 `tableRanges`,如果两个表格范围相邻或仅由空行/特定注释行隔开,则将它们合并为一个范围。 * 这有助于处理因 Markdown 解析不完美或原始文档格式问题导致的表格被错误分割的情况。 * 5. **提取表格并替换为占位符**: * - 从后向前遍历 `tableRanges`(避免替换影响后续行号的准确性)。 * - 对每个表格范围,提取其完整的 Markdown 内容。 * - 生成唯一的占位符,如 `__TABLE_PLACEHOLDER_0__`。 * - 将原始表格内容存储在 `tablePlaceholders` 对象中,键为占位符,值为表格 Markdown 文本。 * - 在原始文本 (`processedText`) 中,用占位符替换掉实际的表格内容。 * - 同时更新 `lines` 数组(用于内部处理),将表格内容替换为占位符,以便后续步骤的正确索引。 * 6. **返回结果**:返回一个对象,包含: * - `processedText`:表格已被占位符替换的 Markdown 文本。 * - `tablePlaceholders`:一个映射对象,键是占位符,值是对应的原始表格 Markdown 内容。 * * @param {string} markdown - 原始的 Markdown 文本内容。 * @returns {Object} 一个包含两部分的对象: * `{ processedText: string, tablePlaceholders: Object }`。 */ function protectMarkdownTables(markdown) { // 预处理:标准化换行符并确保每行表格前没有空格影响识别 let normalizedMarkdown = markdown.replace(/\r\n/g, '\n').replace(/^[ \t]+(\|[\s\S]+)$/gm, '$1'); // 首先检测所有表格边界 // 使用一种更可靠的表格检测方法:寻找连续的表格行(包括表头、分隔行和数据行) // 表格相关的正则表达式 const tableRowRegex = /^\s*\|.*\|\s*$/; // 表格行: | 内容 | const tableSepRegex = /^\s*\|[\s\-:]+\|\s*$/; // 表格分隔行: | --:-- | // 按行分割文本 const lines = normalizedMarkdown.split('\n'); const tableRanges = []; // 存储表格的范围 [开始行, 结束行] // 查找所有可能的表格标题 const tableTitles = {}; // 行号 -> 标题文本 for (let i = 0; i < lines.length; i++) { if ((lines[i].trim().startsWith('TABLE') || lines[i].trim().startsWith('Table') || lines[i].trim().startsWith('表')) && i < lines.length - 1 && tableRowRegex.test(lines[i+1])) { tableTitles[i] = lines[i]; } } // 扫描查找所有表格的范围 let inTable = false; let tableStart = -1; let minTableRows = 3; // 最小的有效表格行数 for (let i = 0; i < lines.length; i++) { const line = lines[i]; const isTableRow = tableRowRegex.test(line); if (!inTable && isTableRow) { // 找到表格开始 tableStart = i; inTable = true; } else if (inTable && !isTableRow) { // 找到表格结束 if (i - tableStart >= minTableRows) { // 表格有足够多的行,是有效表格 // 检查是否有表格标题 const titleIndex = tableStart - 1; if (tableTitles[titleIndex]) { tableRanges.push([titleIndex, i - 1]); delete tableTitles[titleIndex]; // 已使用此标题 } else { tableRanges.push([tableStart, i - 1]); } } inTable = false; tableStart = -1; } } // 处理文档尾部的表格 if (inTable && lines.length - tableStart >= minTableRows) { const titleIndex = tableStart - 1; if (tableTitles[titleIndex]) { tableRanges.push([titleIndex, lines.length - 1]); delete tableTitles[titleIndex]; } else { tableRanges.push([tableStart, lines.length - 1]); } } // 合并相邻的表格(处理错误分割的表格) if (tableRanges.length > 1) { const mergedRanges = [tableRanges[0]]; for (let i = 1; i < tableRanges.length; i++) { const lastRange = mergedRanges[mergedRanges.length - 1]; const currentRange = tableRanges[i]; // 检查两个表格是否相邻或只隔了一个空行或注释行 if (currentRange[0] - lastRange[1] <= 2) { // 检查中间行是否为注释行或空行 const middleLines = lines.slice(lastRange[1] + 1, currentRange[0]); const areAllMiddleLinesNonTable = middleLines.every(line => { return line.trim() === '' || line.trim().startsWith('^') || line.trim().startsWith('*') || line.trim().startsWith('$') || /^\s*\[\^\d+\]:/.test(line.trim()) || line.trim().match(/^[a-zA-Z]?\s*\{\s*\^\s*[a-z]+\s*\}/) || line.trim().match(/^\$\{\s*\^\s*\\?[a-z]+\s*\}\$/); }); if (areAllMiddleLinesNonTable) { // 合并这两个表格范围 lastRange[1] = currentRange[1]; } else { mergedRanges.push(currentRange); } } else { mergedRanges.push(currentRange); } } tableRanges.length = 0; tableRanges.push(...mergedRanges); } // 提取表格并替换为占位符 let tableCounter = 0; const tablePlaceholders = {}; let processedText = normalizedMarkdown; // 从后向前处理,避免替换影响索引 for (let i = tableRanges.length - 1; i >= 0; i--) { const [start, end] = tableRanges[i]; const tableLines = lines.slice(start, end + 1); const tableContent = tableLines.join('\n'); // 生成占位符 const placeholder = `__TABLE_PLACEHOLDER_${tableCounter}__`; tablePlaceholders[placeholder] = tableContent; tableCounter++; // 替换原文中的表格内容 const beforeTable = lines.slice(0, start).join('\n'); const afterTable = lines.slice(end + 1).join('\n'); processedText = beforeTable + (beforeTable ? '\n' : '') + placeholder + (afterTable ? '\n' : '') + afterTable; // 更新lines数组,以便后续处理 lines.splice(start, end - start + 1, placeholder); } // 为日志添加识别结果 console.log(`表格识别完成:找到 ${tableCounter} 个表格`); return { processedText, tablePlaceholders }; } /** * 从大语言模型翻译返回的文本中提取并清理出 Markdown 表格内容。 * 模型返回的表格有时可能被包裹在代码块中,或者包含额外的解释性文字,此函数旨在尽可能准确地提取核心表格。 * * 主要步骤: * 1. **初步清理**:移除字符串首尾的空格。 * 2. **移除代码块标记**:如果文本以 ` ``` `开始和结束,则移除这些标记。 * - 如果代码块内部有语言标识 (如 `markdown` 或 `md`),也一并移除。 * 3. **提取潜在表格标题**:检查清理后文本的第一行是否以 "TABLE" 或 "表" 开头,如果是,则将其视为表格标题并暂存。 * 4. **提取表格行**: * - 遍历剩余的文本行。 * - 如果一行以 `|` 开头,则认为进入了表格内容,将其加入 `tableLines` 数组。 * - 如果已在表格内部,但当前行不以 `|` 开头: * - 若该行为空行或包含表格分隔符特征 (`---`),则仍视为表格的一部分。 * - 否则,认为表格内容结束。 * 5. **组合结果**:将提取到的标题行(如果有)和表格行重新组合成完整的表格 Markdown 文本。 * 6. **返回结果**:如果成功提取到表格内容,则返回该内容;否则返回 `null`。 * * @param {string} translatedText - 从翻译 API 收到的原始响应文本,可能包含表格。 * @returns {string|null} 清理并提取出的 Markdown 表格文本。如果未找到有效表格结构,则返回 `null`。 */ function extractTableFromTranslation(translatedText) { // 清理可能的引号或代码块 let cleanedText = translatedText.trim(); // 移除可能的代码块标记 if (cleanedText.startsWith('```') && cleanedText.endsWith('```')) { cleanedText = cleanedText.substring(3, cleanedText.length - 3).trim(); // 可能的语言标识 if (cleanedText.startsWith('markdown') || cleanedText.startsWith('md')) { cleanedText = cleanedText.substring(cleanedText.indexOf('\n')).trim(); } } // 检查是否有表格标题 - 以TABLE或表开头的行 let titleLine = ''; const lines = cleanedText.split('\n'); const firstLine = lines[0].trim(); if (firstLine.startsWith('TABLE') || firstLine.startsWith('表')) { titleLine = lines.shift(); } // 提取表格内容 - 以|开头的连续行 const tableLines = []; let inTable = false; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine.startsWith('|')) { inTable = true; tableLines.push(line); } else if (inTable) { // 如果已经在表格内,但当前行不以|开头,检查是否是表格分隔行或空行 if (trimmedLine === '' || trimmedLine.includes('---')) { tableLines.push(line); } else { // 真正的非表格内容,表格结束 inTable = false; } } } // 组合标题和表格内容 const result = titleLine ? (titleLine + '\n' + tableLines.join('\n')) : tableLines.join('\n'); return result.length > 0 ? result : null; } /** * 在翻译后的文本中恢复 Markdown 表格,并将原始表格内容(存储在占位符中的)进行翻译后再替换回去。 * 此函数旨在确保表格结构在整个翻译流程中保持不变,同时表格内的文本得到正确翻译。 * * 主要步骤: * 1. **收集待翻译表格**:遍历 `tablePlaceholders` 对象,将每个占位符及其对应的原始表格内容收集到 `tablesToTranslate` 数组中。 * 2. **批量翻译表格 (如果提供了 API 配置)**: * - 检查是否提供了 `apiConfig` 和 `targetLang`。如果未提供或没有需要翻译的表格,则跳过翻译步骤,直接用原始表格替换占位符。 * - **构建专用提示词**:为表格翻译创建特定的系统提示 (`tableSystemPrompt`) 和用户提示 (`tableUserPrompt`)。 * 这些提示词强调保持表格结构(分隔符 `|`, 对齐标记 `:--:`, 行列数, 数学公式等)不变,仅翻译文本内容。 * - **逐个翻译表格**: * - 对 `tablesToTranslate` 中的每个表格: * - 使用 `apiConfig.bodyBuilder` 构建请求体。 * - 调用 `callTranslationApi` 发送翻译请求。 * - 使用 `extractTableFromTranslation` 从翻译结果中提取并清理表格内容。 * - 如果成功提取到翻译后的表格 (`cleanedTable`),则在主文本 (`result`) 中用它替换掉对应的占位符。 * - 如果提取失败或翻译出错,则记录警告/错误,并使用原始表格内容替换占位符作为兜底。 * 3. **直接恢复原始表格 (如果未提供 API 配置或无表格)**: * - 如果跳过了翻译步骤,则直接遍历 `tablePlaceholders`,用原始表格内容替换主文本中的占位符。 * 4. **返回结果**:返回已恢复(并可能已翻译)表格的完整 Markdown 文本。 * * @param {string} translatedText - 包含表格占位符的、已经过初步翻译的 Markdown 文本。 * @param {Object} tablePlaceholders - 一个对象,键是表格占位符 (如 `__TABLE_PLACEHOLDER_0__`),值是对应的原始表格 Markdown 内容。 * @param {Object} [apiConfig=null] - (可选) 用于翻译表格的 API 配置对象。如果为 `null`,表格将不被翻译,直接用原文恢复。 * @param {string} [targetLang=null] - (可选) 目标翻译语言。与 `apiConfig` 一同提供时用于翻译表格。 * @param {string} [model=null] - (可选, 未直接使用,但暗示了 apiConfig 的来源) 使用的模型名称。 * @param {string} [apiKey=null] - (可选, 未直接使用,但暗示了 apiConfig 的来源) API 密钥。 * @param {string} [logContext=""] - (可选) 日志记录的上下文前缀。 * @returns {Promise} 已恢复表格(内容可能已翻译)的 Markdown 文本。 */ async function restoreMarkdownTables(translatedText, tablePlaceholders, apiConfig = null, targetLang = null, model = null, apiKey = null, logContext = "") { let result = translatedText; const tablesToTranslate = []; // 第一步:收集所有需要翻译的表格 for (const [placeholder, tableContent] of Object.entries(tablePlaceholders)) { tablesToTranslate.push({ placeholder, content: tableContent }); } // 第二步:批量翻译所有表格(如果提供了API配置) if (apiConfig && targetLang && tablesToTranslate.length > 0) { if (typeof addProgressLog === "function") { addProgressLog(`${logContext} 准备翻译 ${tablesToTranslate.length} 个表格...`); } // 为表格翻译构建专门的系统提示词 const tableSystemPrompt = `你是一个精确翻译表格的助手。请将表格翻译成${targetLang},严格保持以下格式要求: 1. 保持所有表格分隔符(|)和结构完全不变 2. 保持表格对齐标记(:--:、:--、--:)不变 3. 保持表格的行数和列数完全一致 4. 保持数学公式、符号和百分比等专业内容不变 5. 翻译表格标题(如有)和表格内的文本内容 6. 表格内容与表格外内容要明确区分`; for (let i = 0; i < tablesToTranslate.length; i++) { const table = tablesToTranslate[i]; try { if (typeof addProgressLog === "function") { addProgressLog(`${logContext} 正在翻译第 ${i+1}/${tablesToTranslate.length} 个表格...`); } // 用户提示词 const tableUserPrompt = `请将以下Markdown表格翻译成${targetLang},请确保完全保持表格结构和格式: ${table.content} 注意:请保持表格格式完全不变,包括所有的 | 符号、对齐标记、数学公式和符号。`; // 构建请求体 const requestBody = apiConfig.bodyBuilder ? apiConfig.bodyBuilder(tableSystemPrompt, tableUserPrompt) : { model: apiConfig.modelName, messages: [ { role: "system", content: tableSystemPrompt }, { role: "user", content: tableUserPrompt } ] }; // 调用API翻译表格 const translatedTable = await callTranslationApi(apiConfig, requestBody); // 提取和清理翻译结果中的表格部分 const cleanedTable = extractTableFromTranslation(translatedTable); if (cleanedTable) { // 替换原始占位符为翻译后的表格 result = result.replace(table.placeholder, cleanedTable); } else { // 如果没有提取到表格,使用原始表格 console.warn(`${logContext} 无法从翻译结果中提取表格结构,将使用原始表格`); result = result.replace(table.placeholder, table.content); } } catch (tableError) { console.error(`表格翻译失败:`, tableError); if (typeof addProgressLog === "function") { addProgressLog(`${logContext} 表格 ${i+1} 翻译失败: ${tableError.message},将使用原表格`); } // 如果翻译失败,使用原表格 result = result.replace(table.placeholder, table.content); } } } else { // 没有提供翻译配置,直接恢复原表格 for (const [placeholder, tableContent] of Object.entries(tablePlaceholders)) { result = result.replace(placeholder, tableContent); } } return result; } /** * 诊断并尝试修复 Markdown 表格的常见格式问题。 * 此函数主要关注表头与分隔行的一致性,以及数据行列数与表头的一致性。 * * 主要步骤: * 1. **预处理**:按行分割表格内容,移除空行和行首尾空格。 * 2. **基本检查**:如果行数少于3行(表头、分隔、至少一行数据),则认为不是有效表格或过于简单,直接返回原内容。 * 3. **表头分析**:分割表头行,计算列数 (`columnCount`)。 * 4. **分隔行修复**: * - 检查分隔行 (`lines[1]`) 是否包含必要的 `-` 和 `|` 字符。 * - 如果格式明显错误(如缺少 `-`),则根据 `columnCount` 生成一个标准的分隔行 (`| --- | --- | ... |`) 并替换原分隔行。 * - 如果格式基本正确,但其单元格数量与表头不匹配,也重新生成标准分隔行并替换。 * 5. **数据行修复**: * - 遍历从第三行开始的所有数据行。 * - 分割每行数据,计算其单元格数量。 * - 如果单元格数量与 `columnCount` 不匹配,则尝试通过添加或截断单元格来修复该行,使其列数与表头一致。 * (当前实现是补齐空单元格 `| |`)。 * 6. **返回结果**:返回修复后的表格内容(行通过 `\n` 连接)。 * * @param {string} tableContent - 存在格式问题的 Markdown 表格文本。 * @returns {string} 尝试修复后的 Markdown 表格文本。 */ function diagnoseAndFixTableFormat(tableContent) { // 按行分割表格 const lines = tableContent.split('\n').map(l => l.trim()).filter(l => l); if (lines.length < 3) { console.log("表格行数不足"); return tableContent; // 行数不足,可能不是表格 } // 检查第一行(表头) const headerRow = lines[0]; const headerCells = headerRow.split('|').filter(cell => cell.trim() !== ''); const columnCount = headerCells.length; // 检查第二行(分隔行) const separatorRow = lines[1]; // 如果分隔行不包含足够的"-",可能是格式错误 if (!separatorRow.includes('-') || !separatorRow.includes('|')) { console.log("分隔行格式错误,尝试修复"); // 创建正确的分隔行 let newSeparatorRow = '|'; for (let i = 0; i < columnCount; i++) { newSeparatorRow += ' --- |'; } // 插入新的分隔行 lines.splice(1, 1, newSeparatorRow); } else { // 检查分隔行的列数是否与表头匹配 const separatorCells = separatorRow.split('|').filter(cell => cell.trim() !== ''); if (separatorCells.length !== columnCount) { console.log("分隔行列数与表头不匹配,尝试修复"); // 创建正确的分隔行 let newSeparatorRow = '|'; for (let i = 0; i < columnCount; i++) { newSeparatorRow += ' --- |'; } // 替换分隔行 lines.splice(1, 1, newSeparatorRow); } } // 检查并修复所有数据行 for (let i = 2; i < lines.length; i++) { const dataRow = lines[i]; const dataCells = dataRow.split('|').filter(cell => cell.trim() !== ''); // 如果数据行的列数与表头不匹配,进行修复 if (dataCells.length !== columnCount) { console.log(`行 ${i+1} 列数与表头不匹配,尝试修复`); // 创建正确的数据行 let newDataRow = '|'; for (let j = 0; j < columnCount; j++) { newDataRow += (j < dataCells.length ? ` ${dataCells[j].trim()} |` : ' |'); } // 替换数据行 lines.splice(i, 1, newDataRow); } } return lines.join('\n'); } /** * 尝试修复并验证 Markdown 表格的格式,特别是处理对齐标记行错位的问题。 * 此函数首先调用 `diagnoseAndFixTableFormat` 进行初步的格式修复, * 然后专门检查是否存在多行对齐标记或对齐标记行不在第二行(分隔行)的情况。 * * 主要步骤: * 1. **初步修复**:调用 `diagnoseAndFixTableFormat(tableContent)` 对表格进行基础的格式修正。 * 2. **对齐标记行检查**: * - 将修复后的表格按行分割,并过滤掉空行。 * - 查找所有包含对齐标记(`:--:`, `:--`, `--:`)的行 (`alignmentLines`)。 * - **错位处理**:如果找到了对齐标记行,并且表格的第二行(预期的分隔行位置)并不包含连字符 `-`(表明它可能不是一个正常的分隔行): * - 找到第一个包含对齐标记的行的索引 (`alignmentLineIndex`)。 * - 如果该对齐标记行不在第二行 (`alignmentLineIndex > 1`),则将其移动到第二行的位置,即先删除原来的对齐标记行,然后在第二行处插入它。 * 3. **返回结果**:返回经过上述处理(可能被进一步修复)的表格 Markdown 文本。 * * @param {string} tableContent - 可能包含格式问题(尤其是对齐标记错位)的 Markdown 表格文本。 * @returns {string} 修复后的 Markdown 表格文本。 */ function fixTableFormat(tableContent) { // 先尝试基本的修复 let fixedTable = diagnoseAndFixTableFormat(tableContent); // 检测是否有特殊的对齐标记行放在错误位置的情况 const lines = fixedTable.split('\n').map(l => l.trim()).filter(l => l); // 检查是否有多行包含对齐标记 const alignmentLines = lines.filter(line => line.includes(':--:') || line.includes(':--') || line.includes('--:') ); if (alignmentLines.length > 0 && !lines[1].includes('-')) { // 找到第一个包含对齐标记的行 const alignmentLineIndex = lines.findIndex(line => line.includes(':--:') || line.includes(':--') || line.includes('--:') ); if (alignmentLineIndex > 1) { // 移动对齐行到正确位置 const alignmentLine = lines[alignmentLineIndex]; lines.splice(alignmentLineIndex, 1); lines.splice(1, 0, alignmentLine); fixedTable = lines.join('\n'); } } return fixedTable; } // 将函数添加到processModule对象 if (typeof processModule !== 'undefined') { processModule.protectMarkdownTables = protectMarkdownTables; processModule.extractTableFromTranslation = extractTableFromTranslation; processModule.restoreMarkdownTables = restoreMarkdownTables; processModule.diagnoseAndFixTableFormat = diagnoseAndFixTableFormat; processModule.fixTableFormat = fixTableFormat; }