/** * 内容解析器 * 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; level?: number; children?: ContentNode[]; } /** * 将 LLM 输出的未处理自定义标签转换为 Markdown 等效格式 * 这些标签在 prompt 中定义,但 content-parser 不直接支持 */ function normalizeCustomTags(markdown: string): string { // 高管摘要 → 加粗段落 markdown = markdown.replace(/\n?([\s\S]+?)\n?<\/exec_summary>/g, (_, content) => `**【高管摘要】**\n${content.trim()}\n`); // 核心数据网格 → 加粗段落 markdown = markdown.replace(/([\s\S]+?)<\/metric_grid>/g, (_, content) => `**【核心数据】** ${content.trim()}\n`); // 愿景引言 → 加粗段落 markdown = markdown.replace(/\n?([\s\S]+?)\n?<\/vision_quote>/g, (_, content) => `> ${content.trim()}\n`); // 高亮框 → 标题 + 段落 markdown = markdown.replace(/\n?([\s\S]+?)\n?<\/highlight_box>/g, (_, title, content) => `### ${title || '核心洞察'}\n${content.trim()}\n`); // 步骤框 → 编号列表 markdown = markdown.replace(/\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(/\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(/\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(/([\s\S]+?)<\/citation>/g, (_, content) => `> ${content.trim()}\n`); // 数字卡片 → 加粗段落 markdown = markdown.replace(//g, (_, label, value, note) => `**${label}:${value}**${note ? `(${note})` : ''}\n`); // 信息框 → 段落 markdown = markdown.replace(/\n?([\s\S]+?)\n?<\/info_box>/g, (_, title, content) => `${title ? `**${title}**\n` : ''}${content.trim()}\n`); // KPI 行 → 加粗段落 markdown = markdown.replace(/\n?([\s\S]+?)\n?<\/kpi_row>/g, (_, content) => `**【关键指标】**\n${content.trim()}\n`); // 进度条 → 段落 markdown = markdown.replace(/\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 = { qa_box: /]*>\n([\s\S]+?)\n<\/qa_box>/g, script_box: /]*>\n([\s\S]+?)\n<\/script_box>/g, warn_box: /]*>\n([\s\S]+?)\n<\/warn_box>/g, tip_box: /]*>\n([\s\S]+?)\n<\/tip_box>/g, table: /]*>\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 === '<<>>') { 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 { const nodes = parseContent(markdown); const elements = buildDocxElements(nodes, options); // 导入生成函数 const { generateDocxBuffer } = await import('./docx-builder'); return generateDocxBuffer(elements); } /** * 快速统计内容组件数量 */ export function countContentComponents(markdown: string): Record { return { qaBox: (markdown.match(/