MindMap/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts

1187 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Topic } from '../types/dom'
import type { MindElixirInstance } from '../types'
import { setAttributes } from '../utils'
import { getOffsetLT, isTopic } from '../utils'
const ns = 'http://www.w3.org/2000/svg'
function createSvgDom(height: string, width: string) {
const svg = document.createElementNS(ns, 'svg')
setAttributes(svg, {
version: '1.1',
xmlns: ns,
height,
width,
})
return svg
}
function lineHightToPadding(lineHeight: string, fontSize: string) {
return (parseInt(lineHeight) - parseInt(fontSize)) / 2
}
function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
const g = document.createElementNS(ns, 'g')
let content = ''
if ((tpc as Topic).text) {
content = (tpc as Topic).text.textContent!
} else {
content = tpc.childNodes[0].textContent!
}
const lines = content!.split('\n')
// 计算行高和字体大小
const lineHeight = parseFloat(tpcStyle.lineHeight) || parseFloat(tpcStyle.fontSize) * 1.2
const fontSize = parseFloat(tpcStyle.fontSize)
const paddingTop = parseInt(tpcStyle.paddingTop) || 8
const paddingLeft = parseInt(tpcStyle.paddingLeft) || 8
// 计算节点宽度和文本起始位置
const nodeWidth = tpc.offsetWidth || 200
const textX = x + paddingLeft // 纯文本节点使用左对齐
// 计算实际需要的内容高度
const contentHeight = (lines.length - 1) * lineHeight + fontSize + paddingTop + (parseInt(tpcStyle.paddingBottom) || 8)
const nodeHeight = tpc.offsetHeight || 100
const actualHeight = Math.min(contentHeight, nodeHeight) // 不超过原始节点高度
// 创建背景矩形,使用计算出的实际高度
const bg = document.createElementNS(ns, 'rect')
setAttributes(bg, {
x: x + '',
y: y + '',
rx: tpcStyle.borderRadius || '8',
ry: tpcStyle.borderRadius || '8',
width: nodeWidth + 'px',
height: actualHeight + 'px',
fill: tpcStyle.backgroundColor || 'white',
stroke: tpcStyle.borderColor || '#ccc',
'stroke-width': tpcStyle.borderWidth || '1',
})
g.appendChild(bg)
lines.forEach((line, index) => {
const text = document.createElementNS(ns, 'text')
setAttributes(text, {
x: textX + '',
y: y + paddingTop + fontSize + (lineHeight * index) + '',
'text-anchor': 'start',
'font-family': tpcStyle.fontFamily,
'font-size': `${tpcStyle.fontSize}`,
'font-weight': `${tpcStyle.fontWeight}`,
fill: `${tpcStyle.color}`,
})
text.innerHTML = line
g.appendChild(text)
})
console.log('✅ generateSvgText优化了底部间距:', {
originalHeight: nodeHeight,
actualHeight: actualHeight,
linesCount: lines.length
})
return g
}
// 表格转SVG转换器类
class TableToSVGConverter {
private table: HTMLTableElement
private cellWidth: number
private cellHeight: number
private fontSize: number
private fontFamily: string
constructor(table: HTMLTableElement, fontSize: number, fontFamily: string) {
this.table = table
this.fontSize = fontSize
this.fontFamily = fontFamily
this.cellWidth = 80 // 默认单元格宽度
this.cellHeight = 30 // 默认单元格高度
}
// 分析表格结构处理rowspan和colspan
analyzeStructure() {
const structure: Array<{
row: number
col: number
rowspan: number
colspan: number
content: string
isHeader: boolean
}> = []
const rows = this.table.querySelectorAll('tr')
rows.forEach((row, rowIndex) => {
const cells = row.querySelectorAll('td, th')
let colIndex = 0
cells.forEach(cell => {
const htmlCell = cell as HTMLTableCellElement
const rowspan = parseInt(htmlCell.getAttribute('rowspan') || '1')
const colspan = parseInt(htmlCell.getAttribute('colspan') || '1')
const content = htmlCell.textContent?.trim() || ''
const isHeader = htmlCell.tagName.toLowerCase() === 'th'
structure.push({
row: rowIndex,
col: colIndex,
rowspan,
colspan,
content,
isHeader
})
colIndex += colspan
})
})
return structure
}
// 计算布局尺寸
calculateLayout(structure: any[]) {
const maxCols = Math.max(...structure.map(cell => cell.col + cell.colspan))
const maxRows = Math.max(...structure.map(cell => cell.row + cell.rowspan))
// 为每列计算最合适的宽度
const columnWidths: number[] = new Array(maxCols).fill(0)
structure.forEach(cell => {
// 计算这个单元格内容需要的宽度
// 中文字符宽度大约是字体大小的1倍英文是0.6倍
let contentWidth = 0
for (const char of cell.content) {
if (/[\u4e00-\u9fa5]/.test(char)) {
// 中文字符
contentWidth += this.fontSize * 1.0
} else {
// 英文字符
contentWidth += this.fontSize * 0.6
}
}
// 加上内边距
contentWidth += 16 // 左右各8px的padding
// 考虑colspan平均分配宽度
const avgWidthPerCol = contentWidth / cell.colspan
// 更新这一行涉及的列的最大宽度
for (let col = cell.col; col < cell.col + cell.colspan; col++) {
columnWidths[col] = Math.max(columnWidths[col], avgWidthPerCol)
}
})
// 设置最小列宽,确保不会太窄
columnWidths.forEach((width, index) => {
columnWidths[index] = Math.max(width, 80) // 最小80px
})
// 计算总宽度
const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0)
// 计算行高,考虑多行文本
this.cellHeight = Math.max(35, this.fontSize * 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.4 + 10)
// 更新这一行涉及的行的高度
for (let row = cell.row; row < cell.row + cell.rowspan; row++) {
rowHeights[row] = Math.max(rowHeights[row], cellHeight)
}
})
const totalHeight = rowHeights.reduce((sum, height) => sum + height, 0)
console.log('📊 TableToSVGConverter calculateLayout 计算结果:', {
maxCols,
maxRows,
totalWidth,
totalHeight,
rowHeights,
columnWidths,
cellHeight: this.cellHeight,
fontSize: this.fontSize
})
return {
totalWidth,
totalHeight,
cols: maxCols,
rows: maxRows,
columnWidths,
rowHeights
}
}
// 生成SVG元素
generateSVG(structure: any[], layout: any, x: number, y: number) {
const svgGroup = document.createElementNS(ns, 'g')
// 绘制表格边框
const tableRect = document.createElementNS(ns, 'rect')
setAttributes(tableRect, {
x: x + '',
y: y + '',
width: layout.totalWidth + '',
height: layout.totalHeight + '',
fill: 'white',
stroke: '#ccc',
'stroke-width': '1'
})
svgGroup.appendChild(tableRect)
console.log('📊 TableToSVGConverter 创建表格背景rect:', {
x, y, width: layout.totalWidth, height: layout.totalHeight
})
// 绘制垂直网格线(使用动态列宽)
let currentX = x
for (let i = 0; i < layout.columnWidths.length - 1; i++) {
currentX += layout.columnWidths[i]
const line = document.createElementNS(ns, 'line')
setAttributes(line, {
x1: currentX + '',
y1: y + '',
x2: currentX + '',
y2: y + layout.totalHeight + '',
stroke: '#ccc',
'stroke-width': '1'
})
svgGroup.appendChild(line)
}
// 绘制水平网格线(使用动态行高)
let currentY = y
for (let i = 0; i < layout.rowHeights.length - 1; i++) {
currentY += layout.rowHeights[i]
const line = document.createElementNS(ns, 'line')
setAttributes(line, {
x1: x + '',
y1: currentY + '',
x2: x + layout.totalWidth + '',
y2: currentY + '',
stroke: '#ccc',
'stroke-width': '1'
})
svgGroup.appendChild(line)
}
// 绘制单元格内容和背景
structure.forEach(cell => {
// 计算单元格的实际位置和尺寸
let cellX = x
for (let i = 0; i < cell.col; i++) {
cellX += layout.columnWidths[i]
}
let cellY = y
for (let i = 0; i < cell.row; i++) {
cellY += layout.rowHeights[i]
}
// 计算单元格宽度考虑colspan
let cellWidth = 0
for (let i = cell.col; i < cell.col + cell.colspan; i++) {
cellWidth += layout.columnWidths[i]
}
// 计算单元格高度考虑rowspan
let cellHeight = 0
for (let i = cell.row; i < cell.row + cell.rowspan; i++) {
cellHeight += layout.rowHeights[i]
}
// 绘制单元格背景
if (cell.isHeader) {
const bgRect = document.createElementNS(ns, 'rect')
setAttributes(bgRect, {
x: cellX + '',
y: cellY + '',
width: cellWidth + '',
height: cellHeight + '',
fill: '#f5f5f5',
stroke: 'none'
})
svgGroup.appendChild(bgRect)
}
// 绘制文本内容
if (cell.content) {
const text = document.createElementNS(ns, 'text')
setAttributes(text, {
x: cellX + cellWidth / 2 + '',
y: cellY + cellHeight / 2 + this.fontSize / 3 + '',
'text-anchor': 'middle',
'dominant-baseline': 'central',
'font-family': this.fontFamily,
'font-size': this.fontSize + '',
'font-weight': cell.isHeader ? 'bold' : 'normal',
fill: '#333'
})
// 处理多行文本
const lines = cell.content.split('\n')
if (lines.length === 1) {
text.textContent = cell.content
} else {
lines.forEach((line: string, index: number) => {
const tspan = document.createElementNS(ns, 'tspan')
setAttributes(tspan, {
x: cellX + cellWidth / 2 + '',
dy: index === 0 ? '0' : '1.2em'
})
tspan.textContent = line
text.appendChild(tspan)
})
}
svgGroup.appendChild(text)
}
})
return svgGroup
}
// 转换表格为SVG
convert(x: number, y: number) {
const structure = this.analyzeStructure()
const layout = this.calculateLayout(structure)
return this.generateSVG(structure, layout, x, y)
}
}
// 清理HTML内容修复SVG解析错误
function cleanHtmlForSvg(html: string): string {
if (!html) return html
// 修复表格中的<br>标签问题 - 将<td>内的<br>替换为空格
let cleanedHtml = html.replace(/<td[^>]*>([^<]*)<br\s*\/?>([^<]*)<\/td>/gi, (match, before, after) => {
return `<td>${before} ${after}</td>`
})
// 修复<th>中的<br>标签问题
cleanedHtml = cleanedHtml.replace(/<th[^>]*>([^<]*)<br\s*\/?>([^<]*)<\/th>/gi, (match, before, after) => {
return `<th>${before} ${after}</th>`
})
// 移除其他可能导致SVG解析错误的标签
cleanedHtml = cleanedHtml.replace(/<br\s*\/?>/gi, ' ')
// 清理重复的格式标记
cleanedHtml = cleanedHtml.replace(/•\s*【/g, '【')
cleanedHtml = cleanedHtml.replace(/•\s*\[/g, '[')
cleanedHtml = cleanedHtml.replace(/•\s*/g, '')
cleanedHtml = cleanedHtml.replace(/•\s*\(/g, '(')
// 确保所有标签都正确闭合
cleanedHtml = cleanedHtml.replace(/<([^>]+)>/g, (match, tagContent) => {
// 检查是否是自闭合标签
const selfClosingTags = ['br', 'hr', 'img', 'input', 'meta', 'link']
const tagName = tagContent.split(' ')[0].toLowerCase()
if (selfClosingTags.includes(tagName)) {
return `<${tagContent} />`
}
return match
})
return cleanedHtml
}
function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
console.log('🚀 generateSvgTextUsingForeignObject 被调用', tpc.textContent?.substring(0, 50))
const g = document.createElementNS(ns, 'g')
// 检查内容来源
const tpcWithNodeObj = tpc as Topic
let htmlContent = ''
// 优先使用dangerouslySetInnerHTML其次使用text.innerHTML
if (tpcWithNodeObj.nodeObj && tpcWithNodeObj.nodeObj.dangerouslySetInnerHTML) {
htmlContent = tpcWithNodeObj.nodeObj.dangerouslySetInnerHTML
console.log('🔍 使用dangerouslySetInnerHTML内容:', htmlContent.substring(0, 200))
} else if (tpcWithNodeObj.text && tpcWithNodeObj.text.innerHTML) {
htmlContent = tpcWithNodeObj.text.innerHTML
console.log('🔍 使用text.innerHTML内容:', htmlContent.substring(0, 200))
}
const hasHTMLContent = htmlContent && htmlContent !== tpc.textContent
const hasTableContent = htmlContent && htmlContent.includes('<table')
console.log('🔍 HTML内容分析:', {
hasHTMLContent,
hasTableContent,
contentLength: htmlContent.length
})
// 如果包含表格使用新的SVG原生转换器
if (hasTableContent) {
console.log('🔄 检测到表格内容使用SVG原生转换器')
// 创建一个临时DOM元素来解析表格
const tempDiv = document.createElement('div')
tempDiv.innerHTML = cleanHtmlForSvg(htmlContent)
const table = tempDiv.querySelector('table') as HTMLTableElement
if (table) {
const fontSize = parseFloat(tpcStyle.fontSize) || 14
const fontFamily = tpcStyle.fontFamily || 'Arial, sans-serif'
const converter = new TableToSVGConverter(table, fontSize, fontFamily)
const tableSVG = converter.convert(x, y)
g.appendChild(tableSVG)
console.log('✅ 表格已转换为SVG原生元素')
return g
}
}
if (hasHTMLContent && !hasTableContent) {
// 清理HTML内容修复SVG解析错误
const cleanedHtml = cleanHtmlForSvg(htmlContent)
console.log('🔍 清理后的HTML内容:', cleanedHtml.substring(0, 200))
// 使用getBoundingClientRect获取精确尺寸
const rect = tpc.getBoundingClientRect()
const nodeWidth = rect.width
const nodeHeight = rect.height
// 检查是否包含任何图片
const hasImages = cleanedHtml.includes('<img')
const hasNodeImage = tpcWithNodeObj.nodeObj && tpcWithNodeObj.nodeObj.image
const hasAnyImage = hasImages || hasNodeImage
console.log('🔍 图片检测详情:', {
nodeWidth,
nodeHeight,
offsetWidth: tpc.offsetWidth,
offsetHeight: tpc.offsetHeight,
rect: rect,
hasImages: hasImages,
hasNodeImage: hasNodeImage,
hasAnyImage: hasAnyImage,
cleanedHtml: cleanedHtml.substring(0, 100),
nodeObj: tpcWithNodeObj.nodeObj
})
// 尝试使用原生SVG文本渲染
try {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = cleanedHtml
tempDiv.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
width: ${nodeWidth}px;
font-family: ${tpcStyle.fontFamily};
font-size: ${tpcStyle.fontSize};
color: ${tpcStyle.color};
`
document.body.appendChild(tempDiv)
// 提取所有文本内容并转换为SVG文本
const allText = tempDiv.textContent || tempDiv.innerText || ''
const lines = allText.split('\n').filter(line => line.trim())
console.log('🔍 提取的文本内容:', lines)
if (lines.length > 0) {
const fontSize = parseFloat(tpcStyle.fontSize) || 14
const lineHeight = fontSize * 1.4
const paddingTop = parseInt(tpcStyle.paddingTop) || 8
const paddingBottom = parseInt(tpcStyle.paddingBottom) || 8
const paddingLeft = parseInt(tpcStyle.paddingLeft) || 8
// 计算实际需要的内容高度
const contentHeight = (lines.length - 1) * lineHeight + fontSize + paddingTop + paddingBottom
const actualHeight = Math.min(contentHeight, nodeHeight) // 不超过原始节点高度
// 根据是否有图片决定对齐方式
const hasImages = cleanedHtml.includes('<img')
const textX = hasImages ? x + nodeWidth / 2 : x + paddingLeft
const startY = y + fontSize + paddingTop
lines.forEach((line, index) => {
const text = document.createElementNS(ns, 'text')
setAttributes(text, {
x: textX + '',
y: startY + (lineHeight * index) + '',
'text-anchor': hasImages ? 'middle' : 'start',
'font-family': tpcStyle.fontFamily,
'font-size': tpcStyle.fontSize,
'font-weight': tpcStyle.fontWeight,
fill: tpcStyle.color,
})
text.innerHTML = line.trim()
g.appendChild(text)
})
// 创建背景矩形,使用计算出的实际高度
const bg = document.createElementNS(ns, 'rect')
setAttributes(bg, {
x: x + '',
y: y + '',
rx: tpcStyle.borderRadius || '8',
ry: tpcStyle.borderRadius || '8',
width: nodeWidth + 'px',
height: actualHeight + 'px',
fill: tpcStyle.backgroundColor || 'white',
stroke: tpcStyle.borderColor || '#ccc',
'stroke-width': tpcStyle.borderWidth || '1',
})
g.insertBefore(bg, g.firstChild) // 将背景放在最前面
document.body.removeChild(tempDiv)
console.log('✅ 使用原生SVG文本渲染成功优化了底部间距:', {
originalHeight: nodeHeight,
actualHeight: actualHeight,
linesCount: lines.length
})
return g
}
document.body.removeChild(tempDiv)
} catch (error) {
console.log('❌ 原生SVG文本渲染失败回退到foreignObject:', error)
}
// 回退到foreignObject方法
const foreignObject = document.createElementNS(ns, 'foreignObject')
setAttributes(foreignObject, {
x: x + '',
y: y + '',
width: nodeWidth + 'px',
height: nodeHeight + 'px',
})
// 创建div容器来包含HTML内容
const div = document.createElement('div')
div.innerHTML = cleanedHtml
// 应用样式,确保与思维导图显示一致
const paddingTop = parseInt(tpcStyle.paddingTop) || 8
const paddingBottom = parseInt(tpcStyle.paddingBottom) || 8
const paddingLeft = parseInt(tpcStyle.paddingLeft) || 8
const paddingRight = parseInt(tpcStyle.paddingRight) || 8
div.style.cssText = `
width: 100%;
height: 100%;
font-family: ${tpcStyle.fontFamily};
font-size: ${tpcStyle.fontSize};
color: ${tpcStyle.color};
background: transparent;
padding: ${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px;
box-sizing: border-box;
overflow: visible;
text-align: center !important;
line-height: 1.4;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
position: relative;
`
// 为表格添加样式
const tables = div.querySelectorAll('table')
tables.forEach(table => {
const htmlTable = table as HTMLTableElement
htmlTable.style.cssText = `
width: 100%;
border-collapse: collapse;
font-size: ${tpcStyle.fontSize};
font-family: ${tpcStyle.fontFamily};
margin: 0 auto 0px auto;
border: 1px solid #ccc;
`
// 为表格单元格添加样式
const cells = table.querySelectorAll('td, th')
cells.forEach(cell => {
const htmlCell = cell as HTMLElement
htmlCell.style.cssText = `
border: 1px solid #ccc;
padding: 4px 8px;
text-align: center;
vertical-align: top;
font-size: ${parseFloat(tpcStyle.fontSize) * 0.9}px;
background: white;
`
})
})
// 优化列表样式,减少底部空白
const lists = div.querySelectorAll('ul, ol')
lists.forEach(list => {
const htmlList = list as HTMLElement
htmlList.style.cssText = `
margin: 2px 0 4px 0 !important;
padding-left: 0 !important;
text-align: center !important;
list-style-position: inside !important;
`
})
const listItems = div.querySelectorAll('li')
listItems.forEach(item => {
const htmlItem = item as HTMLElement
htmlItem.style.cssText = `
margin: 1px 0 !important;
line-height: 1.2 !important;
padding: 0 !important;
text-align: center !important;
list-style-position: inside !important;
`
})
// 为段落文本添加样式
const paragraphs = div.querySelectorAll('p')
paragraphs.forEach(p => {
const htmlP = p as HTMLElement
htmlP.style.cssText = `
margin: 2px 0 !important;
line-height: 1.4 !important;
padding: 0 !important;
text-align: center !important;
`
})
// 为所有文本元素添加样式
const textElements = div.querySelectorAll('span, div, strong, em')
textElements.forEach(element => {
const htmlElement = element as HTMLElement
htmlElement.style.cssText = `
text-align: center !important;
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
`
})
// 添加全局样式覆盖,确保所有元素都应用正确的对齐方式
const globalStyle = document.createElement('style')
globalStyle.textContent = `
* {
text-align: center !important;
margin-left: auto !important;
margin-right: auto !important;
}
`
div.appendChild(globalStyle)
// 为图片元素添加特殊样式处理,彻底解决图片压线问题
const images = div.querySelectorAll('img')
images.forEach((img, index) => {
const htmlImg = img as HTMLImageElement
// 更大的图片尺寸,减少图片和文字距离
const availableWidth = nodeWidth - paddingLeft - paddingRight - 5 // 进一步减少边距
const availableHeight = Math.floor(nodeHeight * 0.7) // 增加到70%高度
htmlImg.style.cssText = `
max-width: ${availableWidth}px !important;
max-height: ${availableHeight}px !important;
width: auto !important;
height: auto !important;
display: block !important;
margin: 8px auto 0px auto !important;
border-radius: 4px;
object-fit: contain !important;
flex-shrink: 0 !important;
box-sizing: border-box !important;
position: relative !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
`
console.log(`🖼️ 图片 ${index + 1} 强制尺寸控制:`, {
nodeWidth,
nodeHeight,
availableWidth,
availableHeight,
paddingLeft,
paddingRight,
bottomMargin: '20px'
})
})
foreignObject.appendChild(div)
// 为ForeignObject创建背景rect
const bg = document.createElementNS(ns, 'rect')
setAttributes(bg, {
x: x + '',
y: y + '',
rx: tpcStyle.borderRadius || '8',
ry: tpcStyle.borderRadius || '8',
width: nodeWidth + 'px',
height: nodeHeight + 'px',
fill: tpcStyle.backgroundColor || 'white',
stroke: tpcStyle.borderColor || '#ccc',
'stroke-width': tpcStyle.borderWidth || '1',
})
g.insertBefore(bg, g.firstChild) // 将背景放在最前面
g.appendChild(foreignObject)
console.log('✅ 使用foreignObject渲染HTML内容hasAnyImage:', hasAnyImage, 'text-align:', hasAnyImage ? 'center' : 'left')
} else {
// 对于纯文本内容使用原生SVG文本渲染
return generateSvgText(tpc, tpcStyle, x, y)
}
return g
}
function createElBox(mei: MindElixirInstance, tpc: Topic) {
// 不再创建背景rect避免与convertDivToSvg重复创建
// 背景rect现在由convertDivToSvg统一管理
console.log('📦 createElBox 被调用但不创建背景rect以避免重复')
return null
}
function convertDivToSvg(mei: MindElixirInstance, tpc: HTMLElement, useForeignObject = false) {
console.log('🔄 convertDivToSvg 被调用useForeignObject:', useForeignObject, 'tpc内容:', tpc.textContent?.substring(0, 50))
const tpcStyle = getComputedStyle(tpc)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
// 检查是否有HTML内容或节点图片如果有则增加高度
const tpcWithNodeObj2 = tpc as Topic
const hasHTMLContent2 = tpcWithNodeObj2.nodeObj && tpcWithNodeObj2.nodeObj.dangerouslySetInnerHTML
const hasImages2 = hasHTMLContent2 && tpcWithNodeObj2.nodeObj.dangerouslySetInnerHTML?.includes('<img')
const hasNodeImage2 = tpcWithNodeObj2.nodeObj && tpcWithNodeObj2.nodeObj.image
const g = document.createElementNS(ns, 'g')
let text: SVGGElement | null = null
// 检查是否有dangerouslySetInnerHTML内容
const tpcWithNodeObj3 = tpc as Topic
const hasTableContent3 = tpcWithNodeObj3.nodeObj && tpcWithNodeObj3.nodeObj.dangerouslySetInnerHTML && tpcWithNodeObj3.nodeObj.dangerouslySetInnerHTML.includes('<table')
console.log('🔍 convertDivToSvg 表格检测:', {
hasNodeObj: !!tpcWithNodeObj3.nodeObj,
hasDangerouslySetInnerHTML: !!(tpcWithNodeObj3.nodeObj && tpcWithNodeObj3.nodeObj.dangerouslySetInnerHTML),
hasTableContent3: hasTableContent3,
content: tpcWithNodeObj3.nodeObj?.dangerouslySetInnerHTML?.substring(0, 100)
})
if (tpcWithNodeObj3.nodeObj && tpcWithNodeObj3.nodeObj.dangerouslySetInnerHTML) {
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'
const converter = new TableToSVGConverter(table, fontSize, fontFamily)
const tableSVG = converter.convert(x, y)
// 直接返回表格SVG不创建额外的背景rect
return tableSVG
}
}
// 对于非表格的dangerouslySetInnerHTML使用ForeignObject
text = generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
} else if (useForeignObject) {
// 对于其他HTML内容也使用ForeignObject
text = generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
} else {
// 对于普通文本内容generateSvgText已经创建了背景rect这里不需要再创建
console.log('📝 处理普通文本内容generateSvgText将创建背景rect')
text = generateSvgText(tpc, tpcStyle, x, y)
}
if (text) {
g.appendChild(text)
}
return g
}
function convertAToSvg(mei: MindElixirInstance, a: HTMLAnchorElement) {
const aStyle = getComputedStyle(a)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, a)
const svgA = document.createElementNS(ns, 'a')
const text = document.createElementNS(ns, 'text')
setAttributes(text, {
x: x + '',
y: y + parseInt(aStyle.fontSize) + '',
'text-anchor': 'start',
'font-family': aStyle.fontFamily,
'font-size': `${aStyle.fontSize}`,
'font-weight': `${aStyle.fontWeight}`,
fill: `${aStyle.color}`,
})
text.innerHTML = a.textContent!
svgA.appendChild(text)
svgA.setAttribute('href', a.href)
return svgA
}
async function convertNodeImageToSvg(mei: MindElixirInstance, tpc: Topic): Promise<SVGImageElement | null> {
const tpcStyle = getComputedStyle(tpc)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
if (!tpc.nodeObj.image) {
return null
}
const imageData = tpc.nodeObj.image
const svgI = document.createElementNS(ns, 'image')
console.log('🖼️ 处理节点图片:', imageData)
// 计算图片位置,为文字预留空间,限制图片尺寸防止压线
const nodeWidth = parseInt(tpcStyle.width)
const nodeHeight = parseInt(tpcStyle.height)
const maxImgWidth = Math.min(nodeWidth - 10, 300) // 进一步减少边距,增加最大宽度
const maxImgHeight = Math.min(nodeHeight * 0.6, 150) // 增加最大高度为节点的60%
const imgWidth = Math.min(imageData.width || 200, maxImgWidth)
const imgHeight = Math.min(imageData.height || 150, maxImgHeight)
// 计算文字内容的高度
let textHeight = 0
if (tpc.text && tpc.text.textContent) {
const textContent = tpc.text.textContent.trim()
if (textContent) {
// 估算文字高度:行数 * 行高
const lines = textContent.split('\n').length
const lineHeight = parseFloat(tpcStyle.lineHeight) || parseFloat(tpcStyle.fontSize) * 1.2
const fontSize = parseFloat(tpcStyle.fontSize)
const paddingTop = parseInt(tpcStyle.paddingTop) || 8
const paddingBottom = parseInt(tpcStyle.paddingBottom) || 8
textHeight = lines * lineHeight + paddingTop + paddingBottom
console.log('📝 文字高度计算:', { lines, lineHeight, fontSize, textHeight })
}
}
// 图片位置:水平居中,垂直位置考虑文字高度,大幅增加底部间距
const imgX = x + (nodeWidth - imgWidth) / 2
const imgY = y + textHeight + 0 // 移除底部间距,让图片贴近底部
try {
// 尝试将图片转换为base64
const base64Url = await imageToBase64(imageData.url)
setAttributes(svgI, {
x: imgX + '',
y: imgY + '',
width: imgWidth + '',
height: imgHeight + '',
href: base64Url,
})
console.log('✅ 成功导出节点图片')
} catch (error) {
console.warn('⚠️ 图片转base64失败使用原始URL:', error)
setAttributes(svgI, {
x: imgX + '',
y: imgY + '',
width: imgWidth + '',
height: imgHeight + '',
href: imageData.url,
})
}
return svgI
}
async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Promise<SVGImageElement> {
const aStyle = getComputedStyle(a)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, a)
const svgI = document.createElementNS(ns, 'image')
// Use imageProxy function if provided, otherwise use original URL
const imageUrl = mei.imageProxy ? mei.imageProxy(a.src) : a.src
// 获取图片的实际显示尺寸,确保与思维导图中的显示一致
const actualWidth = Math.min(parseInt(aStyle.width) || 300, 300)
const actualHeight = Math.min(parseInt(aStyle.height) || 200, 200)
console.log('🖼️ 图片导出尺寸:', {
computedWidth: aStyle.width,
computedHeight: aStyle.height,
actualWidth,
actualHeight,
imageUrl: imageUrl.substring(0, 50)
})
// 尝试将图片转换为base64格式以确保导出时能正确显示
try {
const base64Url = await imageToBase64(imageUrl)
setAttributes(svgI, {
x: x + '',
y: y + '',
width: actualWidth + '',
height: actualHeight + '',
href: base64Url,
})
} catch (error) {
console.warn('Failed to convert image to base64, using original URL:', error)
setAttributes(svgI, {
x: x + '',
y: y + '',
width: actualWidth + '',
height: actualHeight + '',
href: imageUrl,
})
}
return svgI
}
// 将图片URL转换为base64格式
function imageToBase64(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Failed to get canvas context'))
return
}
ctx.drawImage(img, 0, 0)
const base64 = canvas.toDataURL('image/png')
resolve(base64)
} catch (error) {
reject(error)
}
}
img.onerror = () => {
reject(new Error('Failed to load image'))
}
img.src = url
})
}
const padding = 100
const head = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`
const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) => {
console.log('🎯 generateSvg 开始执行noForeignObject:', noForeignObject)
const mapDiv = mei.nodes
const height = mapDiv.offsetHeight + padding * 2
const width = mapDiv.offsetWidth + padding * 2
const svg = createSvgDom(height + 'px', width + 'px')
const g = document.createElementNS(ns, 'svg')
const bgColor = document.createElementNS(ns, 'rect')
setAttributes(bgColor, {
x: '0',
y: '0',
width: `${width}`,
height: `${height}`,
fill: mei.theme.cssVar['--bgcolor'] as string,
})
svg.appendChild(bgColor)
mapDiv.querySelectorAll('.subLines').forEach(item => {
const clone = item.cloneNode(true) as SVGSVGElement
const { offsetLeft, offsetTop } = getOffsetLT(mapDiv, item.parentElement as HTMLElement)
clone.setAttribute('x', `${offsetLeft}`)
clone.setAttribute('y', `${offsetTop}`)
g.appendChild(clone)
})
const mainLine = mapDiv.querySelector('.lines')?.cloneNode(true)
mainLine && g.appendChild(mainLine)
const topiclinks = mapDiv.querySelector('.topiclinks')?.cloneNode(true)
topiclinks && g.appendChild(topiclinks)
const summaries = mapDiv.querySelector('.summary')?.cloneNode(true)
summaries && g.appendChild(summaries)
// 处理所有节点
const nodePromises = Array.from(mapDiv.querySelectorAll<Topic>('me-tpc')).map(async (tpc) => {
// 不再通过createElBox创建背景避免重复rect
// 背景现在由convertDivToSvg统一管理
const bgBox = createElBox(mei, tpc)
if (bgBox) {
g.appendChild(bgBox)
}
// 检查节点内容类型
const hasDangerouslySetInnerHTML = tpc.nodeObj.dangerouslySetInnerHTML
const hasHTMLContent = !!(tpc.text && tpc.text.innerHTML && tpc.text.innerHTML !== tpc.text.textContent)
const hasImage = tpc.nodeObj.image
console.log('🔍 节点内容分析:', {
hasDangerouslySetInnerHTML,
hasHTMLContent,
hasImage,
innerHTML: tpc.text?.innerHTML?.substring(0, 100),
textContent: tpc.text?.textContent?.substring(0, 100)
})
// 处理不同类型的节点内容
if (hasDangerouslySetInnerHTML || hasHTMLContent) {
// 对于HTML内容表格等使用ForeignObject
console.log('✅ 使用ForeignObject渲染HTML内容')
g.appendChild(convertDivToSvg(mei, tpc, noForeignObject ? false : true))
} else if (!hasImage) {
// 对于没有图片的普通文本内容
g.appendChild(convertDivToSvg(mei, tpc, false))
} else {
// 对于有图片的节点,只渲染文字部分(不包含图片)
console.log('📝 渲染有图片节点的文字内容')
g.appendChild(convertDivToSvg(mei, tpc, false))
}
// 如果有图片,单独处理图片
if (hasImage) {
console.log('🖼️ 处理节点图片:', tpc.nodeObj.image)
try {
const imageSvg = await convertNodeImageToSvg(mei, tpc)
if (imageSvg) {
g.appendChild(imageSvg)
}
} catch (error) {
console.error('❌ 图片导出失败:', error)
}
}
})
// 等待所有节点处理完成
await Promise.all(nodePromises)
mapDiv.querySelectorAll('.tags > span').forEach(tag => {
g.appendChild(convertDivToSvg(mei, tag as HTMLElement))
})
mapDiv.querySelectorAll('.icons > span').forEach(icon => {
g.appendChild(convertDivToSvg(mei, icon as HTMLElement))
})
mapDiv.querySelectorAll('.hyper-link').forEach(hl => {
g.appendChild(convertAToSvg(mei, hl as HTMLAnchorElement))
})
// 处理图片元素 - 只处理不在节点内的独立图片
const allImages = Array.from(mapDiv.querySelectorAll('img'))
console.log('🔍 发现的所有图片元素:', allImages.length)
const imgPromises = allImages.map(async (img, index) => {
// 检查图片是否在节点内
const parentNode = img.closest('me-tpc')
const isInForeignObject = img.closest('foreignObject')
console.log(`🖼️ 处理图片 ${index + 1}:`, {
isInNode: !!parentNode,
isInForeignObject: !!isInForeignObject,
src: img.src.substring(0, 50)
})
// 如果图片在节点内,跳过独立处理(节点已经包含了完整内容)
if (parentNode || isInForeignObject) {
console.log('⏭️ 跳过节点内图片')
return null
}
// 只处理独立的图片元素
console.log('✅ 处理独立图片')
return await convertImgToSvg(mei, img)
})
const imgElements = await Promise.all(imgPromises)
const validImgElements = imgElements.filter(imgEl => imgEl !== null)
console.log('📊 有效图片元素数量:', validImgElements.length)
validImgElements.forEach(imgEl => {
if (imgEl) {
g.appendChild(imgEl)
}
})
setAttributes(g, {
x: padding + '',
y: padding + '',
overflow: 'visible',
})
svg.appendChild(g)
return svg
}
const generateSvgStr = (svgEl: SVGSVGElement, injectCss?: string) => {
if (injectCss) svgEl.insertAdjacentHTML('afterbegin', '<style>' + injectCss + '</style>')
return head + svgEl.outerHTML
}
function blobToUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = evt => {
resolve(evt.target!.result as string)
}
reader.onerror = err => {
reject(err)
}
reader.readAsDataURL(blob)
})
}
export const exportSvg = async function (this: MindElixirInstance, noForeignObject = false, injectCss?: string) {
const svgEl = await generateSvg(this, noForeignObject)
const svgString = generateSvgStr(svgEl, injectCss)
const blob = new Blob([svgString], { type: 'image/svg+xml' })
return blob
}
export const exportPng = async function (this: MindElixirInstance, noForeignObject = false, injectCss?: string): Promise<Blob | null> {
const blob = await this.exportSvg(noForeignObject, injectCss)
// use base64 to bypass canvas taint
const url = await blobToUrl(blob)
return new Promise((resolve, reject) => {
const img = new Image()
img.setAttribute('crossOrigin', 'anonymous')
// 增加超时处理
const timeout = setTimeout(() => {
reject(new Error('Image loading timeout'))
}, 10000)
img.onload = () => {
clearTimeout(timeout)
try {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')!
// 设置白色背景
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
canvas.toBlob(resolve, 'image/png', 1)
} catch (error) {
reject(error)
}
}
img.onerror = (error) => {
clearTimeout(timeout)
console.error('Image loading failed:', error)
reject(error)
}
img.src = url
})
}