Files
Agent-System/web/src/engine/replyDocument/renderClipboardHtml.ts
T
WeiCHEN 1fc7d0d01f feat(export): 实现单次智能体回复导出、私密文档保存与隐私防泄露全套闭环 (Phase 0 - Phase 2)
- Phase 0: 实现服务端回复导出策略矩阵引擎 (replyexport.EvaluatePolicy)、数据库迁移 (2026-09-11-message-export-policy.sql)、SSE done 事件挂载与能力探测 API
- Phase 1: 构建前端 ReplyDocumentIR 语法树解析与渲染管道,支持 Markdown / DOCX 文件下载及带格式富文本剪贴板复制
- Phase 2: 实现私密自由文档 (freeform_documents) 领域服务、PostgreSQL 乐观锁版本管理、ACL 隐私隔离、一键共享与右侧栏编辑联动
- 专项修复:
  1. 修复后台 API 路由注册并加入双向拉取同步,确保私密保存实时进入我的私密列表
  2. 实现轻量回复专属 DOCX 生成与静默下载逻辑 (downloadDocxReply)
  3. 将 LaTeX 公式转为标准 MathML 矢量标记,支持粘贴到 Microsoft Word / WPS 自动识别为原生公式
2026-09-13 02:50:21 +00:00

180 lines
7.5 KiB
TypeScript

import katex from 'katex';
import { ReplyDocumentIR, ReplyNode, ReplyInline } from '../../types/replyExport';
import { escapeHtml } from './sanitize';
/**
* renderLatexToMathML: 将 LaTeX 源码转为标准 MathML (<math xmlns="http://www.w3.org/1998/Math/MathML">)
* Microsoft Word (2016+/365)、WPS、Outlook 在粘贴 HTML 时原生解析 MathML 为矢量数学公式 (OMML Equation)
*/
export function renderLatexToMathML(latex: string, isDisplay: boolean): string {
if (!latex || !latex.trim()) return '';
const trimmed = latex.trim();
try {
const raw = katex.renderToString(trimmed, {
output: 'mathml',
displayMode: isDisplay,
throwOnError: false,
});
// KaTeX outputs <span class="katex"><math ...>...</math></span>
const mathMatch = raw.match(/<math[\s\S]*?<\/math>/);
if (mathMatch) {
let mathTag = mathMatch[0];
// 确保携带标准 MathML 命名空间属性
if (!mathTag.includes('xmlns="http://www.w3.org/1998/Math/MathML"')) {
mathTag = mathTag.replace('<math', '<math xmlns="http://www.w3.org/1998/Math/MathML"');
}
if (isDisplay) {
return `<div class="math-block" style="text-align: center; margin: 14px 0; padding: 6px 0;">${mathTag}</div>`;
}
return mathTag;
}
return raw;
} catch (_e) {
if (isDisplay) {
return `<div class="math-block" style="text-align: center; margin: 12px 0;"><code>$$ ${escapeHtml(trimmed)} $$</code></div>`;
}
return `<span class="math-inline">$${escapeHtml(trimmed)}$</span>`;
}
}
/**
* 将 Reply Document IR 渲染为专门用于富文本剪贴板 (text/html) 的办公友好 HTML
* 适用于粘贴到 Microsoft Word、Outlook、WPS、飞书文档及各类富文本编辑器
*/
export function renderClipboardHtml(ir: ReplyDocumentIR): string {
const bodyHtml = ir.nodes.map(renderNodeToHtml).join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #1f2937;
}
h1 { font-size: 18pt; margin: 18px 0 8px; color: #111827; font-weight: bold; }
h2 { font-size: 15pt; margin: 14px 0 6px; color: #1f2937; font-weight: bold; }
h3 { font-size: 13pt; margin: 12px 0 4px; color: #374151; font-weight: bold; }
h4, h5, h6 { font-size: 11pt; margin: 10px 0 4px; color: #4b5563; font-weight: bold; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0 8px 24px; padding: 0; }
li { margin: 4px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #d1d5db; padding: 6px 10px; font-size: 10pt; text-align: left; vertical-align: top; }
th { background-color: #f3f4f6; font-weight: bold; color: #111827; }
blockquote { border-left: 3px solid #6366f1; padding: 4px 12px; margin: 8px 0; color: #4b5563; background-color: #f9fafb; font-style: italic; }
pre { background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 10px; border-radius: 4px; font-family: Consolas, "Courier New", monospace; font-size: 9.5pt; overflow-x: auto; margin: 8px 0; }
code { background-color: #f3f4f6; padding: 2px 4px; border-radius: 3px; font-family: Consolas, "Courier New", monospace; font-size: 9.5pt; color: #b91c1c; }
hr { border: none; border-top: 1px solid #e5e7eb; margin: 16px 0; }
a { color: #2563eb; text-decoration: underline; }
.math-block { text-align: center; margin: 14px 0; padding: 6px 0; }
.math-inline { font-family: "Cambria Math", "Times New Roman", serif; }
</style>
</head>
<body>
${bodyHtml}
</body>
</html>`;
}
function renderNodeToHtml(node: ReplyNode): string {
switch (node.type) {
case 'heading': {
const tag = `h${node.level}`;
return `<${tag}>${node.children.map(renderInlineToHtml).join('')}</${tag}>`;
}
case 'paragraph': {
return `<p style="margin: 8px 0;">${node.children.map(renderInlineToHtml).join('')}</p>`;
}
case 'list': {
const tag = node.ordered ? 'ol' : 'ul';
const itemsHtml = node.items
.map((item) => `<li style="margin: 4px 0;">${item.map(renderInlineToHtml).join('')}</li>`)
.join('\n');
return `<${tag} style="margin: 8px 0 8px 24px; padding: 0;">${itemsHtml}</${tag}>`;
}
case 'table': {
const headerHtml =
node.header.length > 0
? `<thead><tr>${node.header
.map(
(c) =>
`<th style="border: 1px solid #d1d5db; padding: 6px 10px; background-color: #f3f4f6; font-weight: bold; color: #111827; text-align: left; vertical-align: top;">${c
.map(renderInlineToHtml)
.join('')}</th>`
)
.join('')}</tr></thead>`
: '';
const rowsHtml = node.rows
.map(
(row) =>
`<tr>${row
.map(
(c) =>
`<td style="border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; vertical-align: top;">${c
.map(renderInlineToHtml)
.join('')}</td>`
)
.join('')}</tr>`
)
.join('\n');
return `<table style="border-collapse: collapse; width: 100%; margin: 12px 0; border: 1px solid #d1d5db;">\n${headerHtml}\n<tbody>\n${rowsHtml}\n</tbody>\n</table>`;
}
case 'blockquote': {
const inner = node.children.map(renderNodeToHtml).join('\n');
return `<blockquote style="border-left: 3px solid #6366f1; padding: 4px 12px; margin: 8px 0; color: #4b5563; background-color: #f9fafb; font-style: italic;">${inner}</blockquote>`;
}
case 'code': {
return `<pre style="background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 10px; border-radius: 4px; font-family: Consolas, 'Courier New', monospace; font-size: 9.5pt; overflow-x: auto; margin: 8px 0;"><code style="background-color: transparent; padding: 0; color: #1f2937;">${escapeHtml(
node.value
)}</code></pre>`;
}
case 'math': {
return renderLatexToMathML(node.latex, node.display);
}
case 'mermaid': {
return `<pre style="background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 10px; border-radius: 4px; font-family: Consolas, monospace; font-size: 9.5pt; margin: 8px 0;"><code>[Mermaid 图表]\n${escapeHtml(
node.source
)}</code></pre>`;
}
case 'divider': {
return '<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;">';
}
default:
return '';
}
}
function renderInlineToHtml(inline: ReplyInline): string {
switch (inline.type) {
case 'text': {
let val = escapeHtml(inline.value);
if (inline.bold) val = `<strong style="font-weight: bold;">${val}</strong>`;
if (inline.italic) val = `<em style="font-style: italic;">${val}</em>`;
if (inline.strike) val = `<s style="text-decoration: line-through;">${val}</s>`;
return val;
}
case 'code': {
return `<code style="background-color: #f3f4f6; padding: 2px 4px; border-radius: 3px; font-family: Consolas, 'Courier New', monospace; font-size: 9.5pt; color: #b91c1c;">${escapeHtml(
inline.value
)}</code>`;
}
case 'math': {
return renderLatexToMathML(inline.latex, false);
}
case 'link': {
const inner = inline.children.map(renderInlineToHtml).join('');
return `<a href="${escapeHtml(inline.href)}" style="color: #2563eb; text-decoration: underline;">${inner}</a>`;
}
case 'hardBreak': {
return '<br>';
}
default:
return '';
}
}