修复SVG导出表格节点问题:使用foreignObject替代原生SVG文本,解决XML语法错误

- 将表格渲染从TableToSVGConverter改为foreignObject方式
- 修复XML语法错误:字体名加引号、添加命名空间、字符转义
- 解决表格内容压缩、对齐混乱、文本溢出问题
- 实现表格自动换行和列宽自适应
- 确保SVG导出的表格布局与HTML显示一致
This commit is contained in:
lixinran 2025-10-11 01:31:06 +08:00
parent 35766881dd
commit 2a09a6b05c
7 changed files with 525 additions and 494 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -23,7 +23,7 @@
flex-direction: column;
}
</style>
<script type="module" crossorigin src="/assets/index-3cfab743.js"></script>
<script type="module" crossorigin src="/assets/index-3ece160d.js"></script>
<link rel="stylesheet" href="/assets/index-356fe347.css">
</head>
<body>

View File

@ -150,18 +150,24 @@ class TableToSVGConverter {
// 计算这个单元格内容需要的宽度
// 中文字符宽度大约是字体大小的1倍英文是0.6倍
let contentWidth = 0
for (const char of cell.content) {
const lines = cell.content.split('\n')
lines.forEach((line: string) => {
let lineWidth = 0
for (const char of line) {
if (/[\u4e00-\u9fa5]/.test(char)) {
// 中文字符
contentWidth += this.fontSize * 1.0
lineWidth += this.fontSize * 1.0
} else {
// 英文字符
contentWidth += this.fontSize * 0.6
lineWidth += this.fontSize * 0.6
}
}
contentWidth = Math.max(contentWidth, lineWidth)
})
// 加上内边距
contentWidth += 16 // 左右各8px的padding
contentWidth += 20 // 左右各10px的padding
// 考虑colspan平均分配宽度
const avgWidthPerCol = contentWidth / cell.colspan
@ -174,21 +180,21 @@ class TableToSVGConverter {
// 设置最小列宽,确保不会太窄
columnWidths.forEach((width, index) => {
columnWidths[index] = Math.max(width, 80) // 最小80px
columnWidths[index] = Math.max(width, 120) // 增加最小宽度到120px
})
// 计算总宽度
const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0)
// 计算行高,大幅减少
this.cellHeight = Math.max(15, this.fontSize * 1.0) // 大幅减少行高
// 计算行高
this.cellHeight = Math.max(25, this.fontSize * 1.2) // 合理的行高
// 为每行计算实际高度(考虑多行文本)
const rowHeights: number[] = new Array(maxRows).fill(this.cellHeight)
structure.forEach(cell => {
const lines = cell.content.split('\n').length
const cellHeight = Math.max(this.cellHeight, lines * this.fontSize * 1.0 + 2)
const cellHeight = Math.max(this.cellHeight, lines * this.fontSize * 1.3 + 8)
// 更新这一行涉及的行的高度
for (let row = cell.row; row < cell.row + cell.rowspan; row++) {
@ -310,9 +316,9 @@ class TableToSVGConverter {
if (cell.content) {
const text = document.createElementNS(ns, 'text')
setAttributes(text, {
x: cellX + cellWidth / 2 + '',
x: cellX + 10 + '', // 左对齐左边距10px
y: cellY + cellHeight / 2 + this.fontSize / 3 + '',
'text-anchor': 'middle',
'text-anchor': 'start', // 左对齐
'dominant-baseline': 'central',
'font-family': this.fontFamily,
'font-size': this.fontSize + '',
@ -328,7 +334,7 @@ class TableToSVGConverter {
lines.forEach((line: string, index: number) => {
const tspan = document.createElementNS(ns, 'tspan')
setAttributes(tspan, {
x: cellX + cellWidth / 2 + '',
x: cellX + 10 + '', // 每行都左对齐
dy: index === 0 ? '0' : '1.2em'
})
tspan.textContent = line
@ -415,27 +421,59 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
contentLength: htmlContent.length
})
// 如果包含表格,使用新的SVG原生转换器
// 如果包含表格,使用foreignObject方式渲染HTML表格
if (hasTableContent) {
console.log('🔄 检测到表格内容,使用SVG原生转换器')
console.log('🔄 检测到表格内容,使用foreignObject方式渲染HTML表格')
// 创建一个临时DOM元素来解析表格
const tempDiv = document.createElement('div')
tempDiv.innerHTML = cleanHtmlForSvg(htmlContent)
const table = tempDiv.querySelector('table') as HTMLTableElement
// 获取节点尺寸
const nodeWidth = tpc.offsetWidth || 400
const nodeHeight = tpc.offsetHeight || 200
if (table) {
const fontSize = parseFloat(tpcStyle.fontSize) || 14
const fontFamily = tpcStyle.fontFamily || 'Arial, sans-serif'
// 创建背景矩形
const bg = document.createElementNS(ns, 'rect')
setAttributes(bg, {
x: x + '',
y: y + '',
width: nodeWidth + '',
height: nodeHeight + '',
fill: 'white',
stroke: '#ccc',
'stroke-width': '1'
})
g.appendChild(bg)
const converter = new TableToSVGConverter(table, fontSize, fontFamily)
const tableSVG = converter.convert(x, y)
// 创建foreignObject包含HTML表格
const foreignObject = document.createElementNS(ns, 'foreignObject')
setAttributes(foreignObject, {
x: x + '',
y: y + '',
width: nodeWidth + '',
height: nodeHeight + ''
})
g.appendChild(tableSVG)
console.log('✅ 表格已转换为SVG原生元素')
// 创建HTML内容确保XML语法正确
const safeFontFamily = (tpcStyle.fontFamily || 'Arial, sans-serif').replace(/"/g, '&quot;')
const htmlContentForForeignObject = `
<div xmlns="http://www.w3.org/1999/xhtml" style="
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
overflow: hidden;
font-family: '${safeFontFamily}';
font-size: ${tpcStyle.fontSize || '14px'};
line-height: 1.4;
">
${cleanHtmlForSvg(htmlContent)}
</div>
`
foreignObject.innerHTML = htmlContentForForeignObject
g.appendChild(foreignObject)
console.log('✅ 表格已使用foreignObject渲染')
return g
}
}
if (hasHTMLContent && !hasTableContent) {
// 清理HTML内容修复SVG解析错误
@ -474,7 +512,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
top: -9999px;
left: -9999px;
width: ${nodeWidth}px;
font-family: ${tpcStyle.fontFamily};
font-family: '${(tpcStyle.fontFamily || 'Arial').replace(/"/g, '&quot;')}';
font-size: ${tpcStyle.fontSize};
color: ${tpcStyle.color};
`
@ -569,7 +607,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
div.style.cssText = `
width: 100%;
height: 100%;
font-family: ${tpcStyle.fontFamily};
font-family: '${(tpcStyle.fontFamily || 'Arial').replace(/"/g, '&quot;')}';
font-size: ${tpcStyle.fontSize};
color: ${tpcStyle.color};
background: transparent;
@ -592,7 +630,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
width: 100%;
border-collapse: collapse;
font-size: ${tpcStyle.fontSize};
font-family: ${tpcStyle.fontFamily};
font-family: '${(tpcStyle.fontFamily || 'Arial').replace(/"/g, '&quot;')}';
margin: 0 auto 0px auto;
border: 1px solid #ccc;
`
@ -772,27 +810,9 @@ function convertDivToSvg(mei: MindElixirInstance, tpc: HTMLElement, useForeignOb
console.log('🔍 处理dangerouslySetInnerHTML内容:', tpcWithNodeObj3.nodeObj.dangerouslySetInnerHTML.substring(0, 200))
if (hasTableContent3) {
console.log('✅ 检测到表格内容使用TableToSVGConverter')
// 对于表格内容直接使用TableToSVGConverter避免重复的背景rect
const tempDiv = document.createElement('div')
tempDiv.innerHTML = tpcWithNodeObj3.nodeObj.dangerouslySetInnerHTML
const table = tempDiv.querySelector('table') as HTMLTableElement
if (table) {
const fontSize = parseFloat(tpcStyle.fontSize) || 14
const fontFamily = tpcStyle.fontFamily || 'Arial, sans-serif'
// 获取实际DOM元素的尺寸用于精确的rect高度
const rect = tpc.getBoundingClientRect()
const actualHeight = rect.height
const converter = new TableToSVGConverter(table, fontSize, fontFamily)
const tableSVG = converter.convert(x, y, actualHeight)
// 直接返回表格SVG不创建额外的背景rect
return tableSVG
}
console.log('✅ 检测到表格内容使用foreignObject方式渲染')
// 对于表格内容使用foreignObject方式确保表格布局正确
return generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
}
// 对于非表格的dangerouslySetInnerHTML使用ForeignObject
@ -1039,7 +1059,7 @@ const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) =>
if (hasDangerouslySetInnerHTML || hasHTMLContent) {
// 对于HTML内容表格等使用ForeignObject
console.log('✅ 使用ForeignObject渲染HTML内容')
g.appendChild(convertDivToSvg(mei, tpc, noForeignObject ? false : true))
g.appendChild(convertDivToSvg(mei, tpc, !noForeignObject))
} else if (!hasImage) {
// 对于没有图片的普通文本内容
g.appendChild(convertDivToSvg(mei, tpc, false))