Spaces:
Sleeping
Sleeping
| /** | |
| * 内容解析器 | |
| * 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 | |
| }; | |
| } |