diff --git a/web/package.json b/web/package.json index 05f7cdb7..de54cce3 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "node --test" + "test": "tsx --test src/**/*.test.ts" }, "dependencies": { "lucide-react": "^1.16.0", @@ -19,6 +19,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.0", + "tsx": "^4.23.13", "typescript": "~5.9.3", "vite": "^7.1.14" } diff --git a/web/src/engine/autoNumber.ts b/web/src/engine/autoNumber.ts new file mode 100644 index 00000000..ec830790 --- /dev/null +++ b/web/src/engine/autoNumber.ts @@ -0,0 +1,49 @@ +import { DocumentBlock } from '../types/block'; + +/** + * recomputeNumbering: 动态单遍重算整个文档块树的章节多级编号及图表题注 + * - 标题层级栈:1 -> 1.1 -> 1.1.1 + * - 图表计数器:表 1-1、图 1-1 + */ +export function recomputeNumbering(root: DocumentBlock): DocumentBlock { + const chapterStack: number[] = [0, 0, 0, 0, 0, 0]; + let currentTopChapter = 1; + let tableCounter = 1; + let figureCounter = 1; + + function traverse(block: DocumentBlock) { + if (block.type === 'heading') { + const level = Math.max(1, Math.min(6, block.level || 1)); + chapterStack[level - 1] += 1; + for (let i = level; i < chapterStack.length; i++) { + chapterStack[i] = 0; + } + const numStr = chapterStack.slice(0, level).join('.'); + block.numbering = numStr; + + if (level === 1) { + currentTopChapter = chapterStack[0]; + tableCounter = 1; + figureCounter = 1; + } + } else if (block.type === 'table') { + block.numbering = `表 ${currentTopChapter}-${tableCounter++}`; + } else if (block.type === 'mermaid') { + block.numbering = `图 ${currentTopChapter}-${figureCounter++}`; + } + + if (block.children && block.children.length > 0) { + for (const child of block.children) { + traverse(child); + } + } + } + + if (root.children) { + for (const child of root.children) { + traverse(child); + } + } + + return root; +} diff --git a/web/src/engine/blockToMarkdown.ts b/web/src/engine/blockToMarkdown.ts new file mode 100644 index 00000000..08012378 --- /dev/null +++ b/web/src/engine/blockToMarkdown.ts @@ -0,0 +1,85 @@ +import { DocumentAST, DocumentBlock } from '../types/block'; + +/** + * blockASTToMarkdown: 将 Block AST 树无损还原为整洁的标准 Markdown 文本 + */ +export function blockASTToMarkdown(ast: DocumentAST): string { + const chunks: string[] = []; + + function serializeBlock(block: DocumentBlock) { + switch (block.type) { + case 'heading': { + const level = Math.max(1, Math.min(6, block.level || 1)); + const prefix = '#'.repeat(level); + chunks.push(`${prefix} ${block.content}\n`); + break; + } + case 'paragraph': { + if (block.content) { + chunks.push(`${block.content}\n`); + } + break; + } + case 'divider': { + chunks.push('---\n'); + break; + } + case 'callout': { + const typeTag = block.properties?.calloutType === 'regulatory' ? '合规依据' : '提示'; + chunks.push(`> **[${typeTag}]** ${block.content}\n`); + break; + } + case 'mermaid': { + chunks.push('```mermaid\n' + block.content.trim() + '\n```\n'); + break; + } + case 'math': { + chunks.push('$$\n' + block.content.trim() + '\n$$\n'); + break; + } + case 'table': { + const data = block.properties?.tableData; + if (data && data.length > 0) { + const colCount = Math.max(...data.map((r) => r.length)); + const lines: string[] = []; + + // 表头行 + const header = data[0]; + const headerCells = Array.from({ length: colCount }, (_, idx) => header[idx] || ''); + lines.push('| ' + headerCells.join(' | ') + ' |'); + + // 分隔线 + const sep = Array.from({ length: colCount }, () => '---'); + lines.push('| ' + sep.join(' | ') + ' |'); + + // 数据行 + for (let r = 1; r < data.length; r++) { + const row = data[r]; + const rowCells = Array.from({ length: colCount }, (_, idx) => row[idx] || ''); + lines.push('| ' + rowCells.join(' | ') + ' |'); + } + + chunks.push(lines.join('\n') + '\n'); + } + break; + } + case 'document': { + if (block.children) { + for (const child of block.children) { + serializeBlock(child); + } + } + break; + } + default: { + if (block.content) { + chunks.push(`${block.content}\n`); + } + break; + } + } + } + + serializeBlock(ast.rootBlock); + return chunks.join('\n').trim() + '\n'; +} diff --git a/web/src/engine/converters.test.ts b/web/src/engine/converters.test.ts new file mode 100644 index 00000000..81164a89 --- /dev/null +++ b/web/src/engine/converters.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { markdownToBlockAST } from './markdownToBlock'; +import { blockASTToMarkdown } from './blockToMarkdown'; +import { recomputeNumbering } from './autoNumber'; +import { DocumentBlock } from '../types/block'; + +const SAMPLE_MARKDOWN = `# 4K超高清荧光内窥镜系统立项与可行性报告 + +本产品主要用于微创腹腔手术中的组织成像与荧光导航。遵循法规体系如下: + +> **[合规依据]** 本项目需严格遵守 GB 9706.1-2020 与 YY 0569-2011 医用电气安全标准。 + +## 关键技术指标 + +| 参数项 | 指标要求 | 检验依据 | +| 光学分辨率 | 4K (3840x2160) | YY 0068.1 | +| 荧光波长 | 805nm 近红外 | 临床验证 | + +### 电气安全与电磁兼容 + +设备需通过 GB 9706.1-2020 绝缘测试与漏电流测试。 + +\`\`\`mermaid +graph TD + A[主机] --> B[摄像视管] + B --> C[荧光滤光片] +\`\`\` + +$$ +E = mc^2 +$$ + +--- +`; + +test('Markdown ➔ Block AST 解析测试', () => { + const ast = markdownToBlockAST(SAMPLE_MARKDOWN, { + docId: 'doc_feasibility_001', + taskKey: 'feasibility_reg', + }); + + assert.equal(ast.docId, 'doc_feasibility_001'); + assert.equal(ast.rootBlock.children?.length, 10); + + // 验证一级标题与自动编号 + const h1 = ast.rootBlock.children?.[0] as DocumentBlock; + assert.equal(h1.type, 'heading'); + assert.equal(h1.level, 1); + assert.equal(h1.numbering, '1'); + assert.equal(h1.content, '4K超高清荧光内窥镜系统立项与可行性报告'); + + // 验证法规引证识别 + const callout = ast.rootBlock.children?.[2] as DocumentBlock; + assert.equal(callout.type, 'callout'); + assert.ok(callout.properties?.regulations?.includes('GB 9706.1-2020')); + + // 验证二级标题编号 + const h2 = ast.rootBlock.children?.[3] as DocumentBlock; + assert.equal(h2.type, 'heading'); + assert.equal(h2.level, 2); + assert.equal(h2.numbering, '1.1'); + + // 验证表格与题注编号 + const table = ast.rootBlock.children?.[4] as DocumentBlock; + assert.equal(table.type, 'table'); + assert.equal(table.numbering, '表 1-1'); + assert.equal(table.properties?.tableData?.length, 3); // 1 header + 2 rows + + // 验证三级标题编号 + const h3 = ast.rootBlock.children?.[5] as DocumentBlock; + assert.equal(h3.type, 'heading'); + assert.equal(h3.level, 3); + assert.equal(h3.numbering, '1.1.1'); + + // 验证段落 + const p2 = ast.rootBlock.children?.[6] as DocumentBlock; + assert.equal(p2.type, 'paragraph'); + assert.ok(p2.properties?.regulations?.includes('GB 9706.1-2020')); + + // 验证流程图编号 + const mermaid = ast.rootBlock.children?.[7] as DocumentBlock; + assert.equal(mermaid.type, 'mermaid'); + assert.equal(mermaid.numbering, '图 1-1'); +}); + +test('动态重编号引擎测试 (Recompute Numbering)', () => { + const ast = markdownToBlockAST(SAMPLE_MARKDOWN); + const children = ast.rootBlock.children || []; + + // 在开头插入一个新的一级标题 + const newH1: DocumentBlock = { + id: 'new_h1', + type: 'heading', + level: 1, + content: '立项前言', + }; + children.unshift(newH1); + + recomputeNumbering(ast.rootBlock); + + // 原先的 1 应该变为 2,1.1 变为 2.1 + assert.equal(children[0].numbering, '1'); + assert.equal(children[1].numbering, '2'); + assert.equal(children[4].numbering, '2.1'); +}); + +test('Block AST ➔ Markdown 双向无损往返还原测试', () => { + const ast = markdownToBlockAST(SAMPLE_MARKDOWN); + const outputMd = blockASTToMarkdown(ast); + + assert.ok(outputMd.includes('# 4K超高清荧光内窥镜系统立项与可行性报告')); + assert.ok(outputMd.includes('| 参数项 | 指标要求 | 检验依据 |')); + assert.ok(outputMd.includes('```mermaid')); + assert.ok(outputMd.includes('$$')); + assert.ok(outputMd.includes('---')); +}); diff --git a/web/src/engine/markdownToBlock.ts b/web/src/engine/markdownToBlock.ts new file mode 100644 index 00000000..e5a113d3 --- /dev/null +++ b/web/src/engine/markdownToBlock.ts @@ -0,0 +1,228 @@ +import { DocumentAST, DocumentBlock } from '../types/block'; +import { recomputeNumbering } from './autoNumber'; + +const REG_PATTERN = /(?:GB(?:\/T)?\s*\d+(?:\.\d+)?(?:-\d{4})?|YY(?:\/T)?\s*\d+(?:\.\d+)?(?:-\d{4})?|ISO\s*\d+|IEC\s*\d+|MDR|FDA|NMPA)/g; + +function generateId(): string { + return 'blk_' + Math.random().toString(36).substring(2, 10); +} + +/** + * markdownToBlockAST: 将 Markdown 文本解析为 Block AST 树 + */ +export function markdownToBlockAST( + md: string, + meta?: Partial +): DocumentAST { + const lines = md.split(/\r?\n/); + const blocks: DocumentBlock[] = []; + + let i = 0; + while (i < lines.length) { + const rawLine = lines[i]; + const trimmed = rawLine.trim(); + + // 空行跳过 + if (trimmed === '') { + i++; + continue; + } + + // 1. 分隔线 (Divider) + if (/^(\*{3,}|-{3,}|_{3,})$/.test(trimmed)) { + blocks.push({ + id: generateId(), + type: 'divider', + content: '', + }); + i++; + continue; + } + + // 2. 标题 (Heading # ~ ######) + const headingMatch = rawLine.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const titleText = headingMatch[2].trim(); + blocks.push({ + id: generateId(), + type: 'heading', + level, + content: titleText, + }); + i++; + continue; + } + + // 3. 代码围栏 (Code Block: ```mermaid / ```math / ```) + if (trimmed.startsWith('```')) { + const lang = trimmed.slice(3).trim().toLowerCase(); + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + if (i < lines.length && lines[i].trim().startsWith('```')) { + i++; // 跳过结束 fence + } + + const codeContent = codeLines.join('\n'); + if (lang === 'mermaid') { + blocks.push({ + id: generateId(), + type: 'mermaid', + content: codeContent, + properties: { + caption: '系统拓扑与流转时序图', + }, + }); + } else if (lang === 'math' || lang === 'latex') { + blocks.push({ + id: generateId(), + type: 'math', + content: codeContent, + }); + } else { + // 普通段落代码块 + blocks.push({ + id: generateId(), + type: 'paragraph', + content: '```' + lang + '\n' + codeContent + '\n```', + }); + } + continue; + } + + // 4. 数学公式 ($$ ... $$) + if (trimmed.startsWith('$$')) { + const mathLines: string[] = []; + if (trimmed.length > 2 && trimmed.endsWith('$$')) { + // 单行 $$公式$$ + mathLines.push(trimmed.slice(2, -2).trim()); + i++; + } else { + i++; + while (i < lines.length && !lines[i].trim().endsWith('$$')) { + mathLines.push(lines[i]); + i++; + } + if (i < lines.length && lines[i].trim().endsWith('$$')) { + const last = lines[i].trim(); + if (last !== '$$') { + mathLines.push(last.replace(/\$\$$/, '').trim()); + } + i++; + } + } + blocks.push({ + id: generateId(), + type: 'math', + content: mathLines.join('\n'), + }); + continue; + } + + // 5. 引用/合规警示块 (> ...) + if (trimmed.startsWith('>')) { + const calloutLines: string[] = []; + while (i < lines.length && lines[i].trim().startsWith('>')) { + calloutLines.push(lines[i].trim().replace(/^>\s?/, '')); + i++; + } + const calloutText = calloutLines.join(' '); + const regs = Array.from(calloutText.matchAll(REG_PATTERN), (m) => m[0]); + blocks.push({ + id: generateId(), + type: 'callout', + content: calloutText, + properties: { + calloutType: regs.length > 0 ? 'regulatory' : 'info', + regulations: regs, + }, + }); + continue; + } + + // 6. GFM 表格 (| col1 | col2 |) + if (trimmed.startsWith('|') && trimmed.endsWith('|')) { + const tableRows: string[][] = []; + while (i < lines.length && lines[i].trim().startsWith('|') && lines[i].trim().endsWith('|')) { + const curRow = lines[i].trim(); + // 忽略表头分割线行 |---|---| + if (!/^\|(?:\s*:?-+:?\s*\|)+$/.test(curRow)) { + const cells = curRow + .slice(1, -1) + .split('|') + .map((c) => c.trim()); + tableRows.push(cells); + } + i++; + } + + blocks.push({ + id: generateId(), + type: 'table', + content: '', + properties: { + tableData: tableRows, + caption: '技术参数与法规符合性对照表', + }, + }); + continue; + } + + // 7. 常规段落 (Paragraph) + const paraLines: string[] = []; + while ( + i < lines.length && + lines[i].trim() !== '' && + !lines[i].trim().startsWith('#') && + !lines[i].trim().startsWith('```') && + !lines[i].trim().startsWith('$$') && + !lines[i].trim().startsWith('>') && + !(lines[i].trim().startsWith('|') && lines[i].trim().endsWith('|')) && + !/^(\*{3,}|-{3,}|_{3,})$/.test(lines[i].trim()) + ) { + paraLines.push(lines[i]); + i++; + } + + const paraContent = paraLines.join('\n'); + const regs = Array.from(paraContent.matchAll(REG_PATTERN), (m) => m[0]); + + blocks.push({ + id: generateId(), + type: 'paragraph', + content: paraContent, + properties: { + regulations: regs.length > 0 ? regs : undefined, + }, + }); + } + + const rootBlock: DocumentBlock = { + id: 'root-doc', + type: 'document', + content: '', + children: blocks, + }; + + // 执行自动重编号 + recomputeNumbering(rootBlock); + + return { + docId: meta?.docId || 'doc_' + Math.random().toString(36).substring(2, 8), + title: meta?.title || (blocks[0]?.type === 'heading' ? blocks[0].content : '未命名受控文档'), + taskKey: meta?.taskKey || 'feasibility_reg', + version: meta?.version || 'V1.0', + lifecycleState: meta?.lifecycleState || 'drafting', + isClaimed: meta?.isClaimed ?? false, + author: meta?.author || '张工', + reviewer: meta?.reviewer || '李主管', + approver: meta?.approver || '王总监', + summary: meta?.summary || '由智能体小队自动生成的受控交付物', + updatedAt: meta?.updatedAt || new Date().toISOString().replace('T', ' ').substring(0, 16), + rootBlock, + }; +}