paper-burner/js/process/tables.js

506 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string>} 已恢复表格(内容可能已翻译)的 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;
}