6k6g-v3 / src /lib /docx-engine /content-parser.ts
fanyubo
fix: DOCX 内容解析器支持所有自定义标签 + 行内格式
df86a7e
Raw
History Blame Contribute Delete
12.9 kB
/**
* 内容解析器
* XML 标签转 AST,支持 qa_box, script_box, warn_box, tip_box, table
*/
import { Paragraph, Table } from 'docx';
import {
qaBox,
scriptBox,
warnBox,
tipBox,
professionalTable,
h1,
h2,
h3,
p,
bullet,
blank,
divider,
pageBreak,
generateCover
} from './docx-builder';
export type ContentNodeType =
| 'paragraph'
| 'heading'
| 'qa_box'
| 'script_box'
| 'warn_box'
| 'tip_box'
| 'table'
| 'bullet'
| 'divider'
| 'page_break'
| 'cover';
export interface ContentNode {
type: ContentNodeType;
content: string | Record<string, any>;
level?: number;
children?: ContentNode[];
}
/**
* 将 LLM 输出的未处理自定义标签转换为 Markdown 等效格式
* 这些标签在 prompt 中定义,但 content-parser 不直接支持
*/
function normalizeCustomTags(markdown: string): string {
// 高管摘要 → 加粗段落
markdown = markdown.replace(/<exec_summary>\n?([\s\S]+?)\n?<\/exec_summary>/g,
(_, content) => `**【高管摘要】**\n${content.trim()}\n`);
// 核心数据网格 → 加粗段落
markdown = markdown.replace(/<metric_grid>([\s\S]+?)<\/metric_grid>/g,
(_, content) => `**【核心数据】** ${content.trim()}\n`);
// 愿景引言 → 加粗段落
markdown = markdown.replace(/<vision_quote>\n?([\s\S]+?)\n?<\/vision_quote>/g,
(_, content) => `> ${content.trim()}\n`);
// 高亮框 → 标题 + 段落
markdown = markdown.replace(/<highlight_box\s*(?:title="([^"]*)")?\s*>\n?([\s\S]+?)\n?<\/highlight_box>/g,
(_, title, content) => `### ${title || '核心洞察'}\n${content.trim()}\n`);
// 步骤框 → 编号列表
markdown = markdown.replace(/<step_box>\n?([\s\S]+?)\n?<\/step_box>/g,
(_, content) => {
const lines = content.trim().split('\n').filter((l: string) => l.trim());
return lines.map((l: string, i: number) => {
const text = l.replace(/^\d+\.\s*/, '').trim();
return `${i + 1}. ${text}`;
}).join('\n') + '\n';
});
// 对比框 → 表格格式
markdown = markdown.replace(/<comparison_box\s+left_title="([^"]*)"\s+right_title="([^"]*)">\n?([\s\S]+?)\n?<\/comparison_box>/g,
(_, leftTitle, rightTitle, content) => {
const lines = content.trim().split('\n').filter((l: string) => l.trim());
const header = `| ${leftTitle} | ${rightTitle} |\n| --- | --- |`;
const rows = lines.map((l: string) => {
const parts = l.split('|').map((p: string) => p.trim());
return `| ${parts[0] || ''} | ${parts[1] || ''} |`;
}).join('\n');
return `${header}\n${rows}\n`;
});
// 检查清单 → 编号列表
markdown = markdown.replace(/<checklist\s*(?:title="([^"]*)")?\s*>\n?([\s\S]+?)\n?<\/checklist>/g,
(_, title, content) => {
const lines = content.trim().split('\n').filter((l: string) => l.trim());
const header = title ? `### ${title}\n` : '';
return header + lines.map((l: string) => `- ${l.replace(/^\[[ x]\]\s*/, '').trim()}`).join('\n') + '\n';
});
// 引用 → 加粗段落
markdown = markdown.replace(/<citation>([\s\S]+?)<\/citation>/g,
(_, content) => `> ${content.trim()}\n`);
// 数字卡片 → 加粗段落
markdown = markdown.replace(/<number_card\s+label="([^"]*)"\s+value="([^"]*)"(?:\s+note="([^"]*)")?\s*\/?>/g,
(_, label, value, note) => `**${label}${value}**${note ? `(${note})` : ''}\n`);
// 信息框 → 段落
markdown = markdown.replace(/<info_box\s*(?:title="([^"]*)")?\s*>\n?([\s\S]+?)\n?<\/info_box>/g,
(_, title, content) => `${title ? `**${title}**\n` : ''}${content.trim()}\n`);
// KPI 行 → 加粗段落
markdown = markdown.replace(/<kpi_row>\n?([\s\S]+?)\n?<\/kpi_row>/g,
(_, content) => `**【关键指标】**\n${content.trim()}\n`);
// 进度条 → 段落
markdown = markdown.replace(/<phase_bar\s*(?:label="([^"]*)")?\s*>\n?([\s\S]+?)\n?<\/phase_bar>/g,
(_, label, content) => `${label ? `**${label}**\n` : ''}${content.trim()}\n`);
return markdown;
}
/**
* 解析 Markdown + XML 标签内容
*/
export function parseContent(markdown: string): ContentNode[] {
// ★ 预处理:将未支持的自定义标签转换为 Markdown 等效格式
markdown = normalizeCustomTags(markdown);
const nodes: ContentNode[] = [];
let remaining = markdown;
// 定义标签匹配模式
const patterns: Record<string, RegExp> = {
qa_box: /<qa_box\s+question="([^"]+)"[^>]*>\n([\s\S]+?)\n<\/qa_box>/g,
script_box: /<script_box\s+scene="([^"]+)"[^>]*>\n([\s\S]+?)\n<\/script_box>/g,
warn_box: /<warn_box[^>]*>\n([\s\S]+?)\n<\/warn_box>/g,
tip_box: /<tip_box\s+title="([^"]+)"[^>]*>\n([\s\S]+?)\n<\/tip_box>/g,
table: /<table\s+headers="([^"]+)"[^>]*>\n([\s\S]+?)\n<\/table>/g
};
// 提取所有标签位置
const tagPositions: Array<{ start: number; end: number; node: ContentNode }> = [];
// 解析 qa_box
let match;
while ((match = patterns.qa_box.exec(markdown)) !== null) {
tagPositions.push({
start: match.index,
end: match.index + match[0].length,
node: {
type: 'qa_box',
content: {
question: match[1],
answers: match[2].split('\n').filter(l => l.trim())
}
}
});
}
// 解析 script_box
patterns.script_box.lastIndex = 0;
while ((match = patterns.script_box.exec(markdown)) !== null) {
tagPositions.push({
start: match.index,
end: match.index + match[0].length,
node: {
type: 'script_box',
content: {
scene: match[1],
lines: match[2].split('\n').filter(l => l.trim())
}
}
});
}
// 解析 warn_box
patterns.warn_box.lastIndex = 0;
while ((match = patterns.warn_box.exec(markdown)) !== null) {
tagPositions.push({
start: match.index,
end: match.index + match[0].length,
node: {
type: 'warn_box',
content: {
lines: match[1].split('\n').filter(l => l.trim())
}
}
});
}
// 解析 tip_box
patterns.tip_box.lastIndex = 0;
while ((match = patterns.tip_box.exec(markdown)) !== null) {
tagPositions.push({
start: match.index,
end: match.index + match[0].length,
node: {
type: 'tip_box',
content: {
title: match[1],
lines: match[2].split('\n').filter(l => l.trim())
}
}
});
}
// 解析 table
patterns.table.lastIndex = 0;
while ((match = patterns.table.exec(markdown)) !== null) {
const headers = match[1].split(',').map(h => h.trim());
const rows = match[2]
.split('\n')
.filter(l => l.trim())
.map(row => row.split('|').map(cell => cell.trim()));
tagPositions.push({
start: match.index,
end: match.index + match[0].length,
node: {
type: 'table',
content: { headers, rows, colWidths: calculateColWidths(headers, rows) }
}
});
}
// 按位置排序
tagPositions.sort((a, b) => a.start - b.start);
// 解析普通 Markdown 文本(非标签部分)
let lastEnd = 0;
for (const pos of tagPositions) {
// 处理标签之前的文本
if (pos.start > lastEnd) {
const textBetween = markdown.slice(lastEnd, pos.start);
const textNodes = parseMarkdownText(textBetween);
nodes.push(...textNodes);
}
// 添加标签节点
nodes.push(pos.node);
lastEnd = pos.end;
}
// 处理剩余文本
if (lastEnd < markdown.length) {
const remainingText = markdown.slice(lastEnd);
const textNodes = parseMarkdownText(remainingText);
nodes.push(...textNodes);
}
return nodes;
}
/**
* 解析普通 Markdown 文本
*/
function parseMarkdownText(text: string): ContentNode[] {
const nodes: ContentNode[] = [];
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
// 空行
nodes.push({ type: 'paragraph', content: '' });
continue;
}
// 标题
if (trimmed.startsWith('# ')) {
nodes.push({ type: 'heading', content: trimmed.slice(2), level: 1 });
} else if (trimmed.startsWith('## ')) {
nodes.push({ type: 'heading', content: trimmed.slice(3), level: 2 });
} else if (trimmed.startsWith('### ')) {
nodes.push({ type: 'heading', content: trimmed.slice(4), level: 3 });
}
// 列表
else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
nodes.push({ type: 'bullet', content: trimmed.slice(2), level: 0 });
} else if (trimmed.match(/^\d+\./)) {
nodes.push({ type: 'bullet', content: trimmed.replace(/^\d+\.\s*/, ''), level: 0 });
}
// 分隔线
else if (trimmed === '---' || trimmed === '***') {
nodes.push({ type: 'divider', content: '' });
}
// 分页
else if (trimmed === '<<<PAGE_BREAK>>>') {
nodes.push({ type: 'page_break', content: '' });
}
// 普通段落
else {
nodes.push({ type: 'paragraph', content: trimmed });
}
}
return nodes;
}
/**
* 计算表格列宽
*/
function calculateColWidths(headers: string[], rows: string[][]): number[] {
const colCount = headers.length;
const defaultWidth = 9000 / colCount;
// 根据内容长度调整宽度
const maxWidths = headers.map((h, i) => {
const headerLen = h.length;
const maxRowLen = Math.max(...rows.map(r => (r[i] || '').length));
return Math.max(headerLen, maxRowLen);
});
// 按比例分配宽度
const totalMax = maxWidths.reduce((a, b) => a + b, 0);
return maxWidths.map(w => Math.round((w / totalMax) * 9000));
}
/**
* 将 AST 转换为 Docx 元素
*/
export function buildDocxElements(
nodes: ContentNode[],
options?: { title?: string; version?: string; confidentiality?: string }
): (Paragraph | Table)[] {
const elements: (Paragraph | Table)[] = [];
// 添加封面
if (options?.title) {
elements.push(...generateCover(
options.title,
options.version || 'v1.0',
options.confidentiality || '内部机密,禁止外传'
));
}
for (const node of nodes) {
switch (node.type) {
case 'heading':
if (node.level === 1) {
elements.push(h1(node.content as string));
} else if (node.level === 2) {
elements.push(h2(node.content as string));
} else {
elements.push(h3(node.content as string));
}
break;
case 'paragraph':
if ((node.content as string).trim()) {
elements.push(p(node.content as string));
} else {
elements.push(blank());
}
break;
case 'bullet':
elements.push(bullet(node.content as string, node.level || 0));
break;
case 'qa_box':
const qaData = node.content as { question: string; answers: string[] };
elements.push(qaBox(qaData.question, qaData.answers));
break;
case 'script_box':
const scriptData = node.content as { scene: string; lines: string[] };
elements.push(scriptBox(scriptData.scene, scriptData.lines));
break;
case 'warn_box':
const warnData = node.content as { lines: string[] };
elements.push(warnBox(warnData.lines));
break;
case 'tip_box':
const tipData = node.content as { title: string; lines: string[] };
elements.push(tipBox(tipData.title, tipData.lines));
break;
case 'table':
const tableData = node.content as {
headers: string[];
rows: string[][];
colWidths: number[];
};
elements.push(professionalTable(tableData.headers, tableData.rows, tableData.colWidths));
break;
case 'divider':
elements.push(divider());
break;
case 'page_break':
elements.push(pageBreak());
break;
}
}
return elements;
}
/**
* 完整解析流程:Markdown → AST → Docx Elements → Buffer
*/
export async function markdownToDocx(
markdown: string,
options?: {
title?: string;
version?: string;
confidentiality?: string;
}
): Promise<Buffer> {
const nodes = parseContent(markdown);
const elements = buildDocxElements(nodes, options);
// 导入生成函数
const { generateDocxBuffer } = await import('./docx-builder');
return generateDocxBuffer(elements);
}
/**
* 快速统计内容组件数量
*/
export function countContentComponents(markdown: string): Record<string, number> {
return {
qaBox: (markdown.match(/<qa_box/g) || []).length,
scriptBox: (markdown.match(/<script_box/g) || []).length,
warnBox: (markdown.match(/<warn_box/g) || []).length,
tipBox: (markdown.match(/<tip_box/g) || []).length,
table: (markdown.match(/<table/g) || []).length,
heading: (markdown.match(/^#{1,3}\s/gm) || []).length,
bullet: (markdown.match(/^[-*]\s/gm) || []).length
};
}