diff --git a/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts b/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts index 1813424..0ed9772 100644 --- a/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts +++ b/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts @@ -52,6 +52,296 @@ function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: num 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) + + 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) + + // 绘制垂直网格线(使用动态列宽) + 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 + + // 修复表格中的
标签问题 - 将内的
替换为空格 + let cleanedHtml = html.replace(/]*>([^<]*)([^<]*)<\/td>/gi, (match, before, after) => { + return `${before} ${after}` + }) + + // 修复中的
标签问题 + cleanedHtml = cleanedHtml.replace(/]*>([^<]*)([^<]*)<\/th>/gi, (match, before, after) => { + return `${before} ${after}` + }) + + // 移除其他可能导致SVG解析错误的标签 + cleanedHtml = cleanedHtml.replace(//gi, ' ') + + // 确保所有标签都正确闭合 + 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) { const g = document.createElementNS(ns, 'g') @@ -77,33 +367,76 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD 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)) + // 对于HTML内容,使用foreignObject来正确渲染 const foreignObject = document.createElementNS(ns, 'foreignObject') + + // 获取节点的实际尺寸,并大幅增加高度以防止图片压线 + const nodeWidth = parseInt(tpcStyle.width) || 200 + const originalHeight = parseInt(tpcStyle.height) || 100 + const nodeHeight = originalHeight + 50 // 大幅增加50px高度给图片留出空间 + setAttributes(foreignObject, { x: x + '', y: y + '', - width: tpcStyle.width, - height: tpcStyle.height, + width: nodeWidth + 'px', + height: nodeHeight + 'px', }) // 创建div容器来包含HTML内容 const div = document.createElement('div') - div.innerHTML = htmlContent + div.innerHTML = cleanedHtml + + // 应用样式,确保与思维导图显示一致,大幅增加底部padding防止图片压线 + const paddingTop = parseInt(tpcStyle.paddingTop) || 8 + const paddingBottom = Math.max(parseInt(tpcStyle.paddingBottom) || 8, 35) // 大幅增加底部padding到35px + const paddingLeft = parseInt(tpcStyle.paddingLeft) || 8 + const paddingRight = parseInt(tpcStyle.paddingRight) || 8 - // 应用样式,确保与思维导图显示一致 div.style.cssText = ` width: 100%; - height: 100%; + height: auto; + min-height: 100%; font-family: ${tpcStyle.fontFamily}; font-size: ${tpcStyle.fontSize}; color: ${tpcStyle.color}; background: transparent; - padding: ${tpcStyle.padding}; + padding: ${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px; box-sizing: border-box; overflow: visible; text-align: center; line-height: 1.4; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + position: relative; ` // 为表格添加样式 @@ -134,12 +467,12 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD }) }) - // 为其他元素添加样式 + // 优化列表样式,减少底部空白 const lists = div.querySelectorAll('ul, ol') lists.forEach(list => { const htmlList = list as HTMLElement htmlList.style.cssText = ` - margin: 4px 0; + margin: 2px 0 4px 0; padding-left: 20px; text-align: left; ` @@ -149,11 +482,50 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD listItems.forEach(item => { const htmlItem = item as HTMLElement htmlItem.style.cssText = ` - margin: 2px 0; - line-height: 1.3; + margin: 1px 0; + line-height: 1.2; + padding: 0; ` }) + // 为图片元素添加特殊样式处理,彻底解决图片压线问题 + 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 20px 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) g.appendChild(foreignObject) console.log('✅ 使用foreignObject渲染HTML内容') @@ -169,6 +541,15 @@ function createElBox(mei: MindElixirInstance, tpc: Topic) { const tpcStyle = getComputedStyle(tpc) const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc) + // 检查是否有HTML内容或节点图片,如果有则增加高度 + const tpcWithNodeObj = tpc as Topic + const hasHTMLContent = tpcWithNodeObj.nodeObj && tpcWithNodeObj.nodeObj.dangerouslySetInnerHTML + const hasImages = hasHTMLContent && tpcWithNodeObj.nodeObj.dangerouslySetInnerHTML?.includes(' // 对于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)) } @@ -458,16 +872,36 @@ const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) => g.appendChild(convertAToSvg(mei, hl as HTMLAnchorElement)) }) // 处理图片元素 - 只处理不在节点内的独立图片 - const imgPromises = Array.from(mapDiv.querySelectorAll('img')).map(async (img) => { - // 检查图片是否在节点内,如果在节点内则跳过(因为节点已经包含了图片和文字) - const isInNode = img.closest('me-tpc') - if (isInNode) { - return null // 跳过节点内的图片,因为节点已经包含了完整内容 + 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) - imgElements.forEach(imgEl => { + const validImgElements = imgElements.filter(imgEl => imgEl !== null) + console.log('📊 有效图片元素数量:', validImgElements.length) + + validImgElements.forEach(imgEl => { if (imgEl) { g.appendChild(imgEl) }