From 8b8ce52e3aa55dfa35d4c95743ffa530c273296b Mon Sep 17 00:00:00 2001 From: lixinran Date: Wed, 15 Oct 2025 15:24:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96SVG=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改进位置计算:处理滚动偏移、transform/scale、SVG坐标归一化 - 优化HTML内容渲染:样式内联化、限制宽高、文字换行处理 - 增强图片处理:统一Base64转换、异步加载管理、大小控制 - 统一背景管理:消除重复创建、统一参数管理 - 完善异步渲染同步:Promise+图片加载检测、整体渲染等待 解决了SVG导出中的位置偏移、内容缺失、图片显示等问题 --- .../lib/mind-elixir/src/plugin/exportImage.ts | 383 ++++++++++++++---- 1 file changed, 303 insertions(+), 80 deletions(-) diff --git a/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts b/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts index 3618e4e..2ec3b16 100644 --- a/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts +++ b/frontend/src/lib/mind-elixir/src/plugin/exportImage.ts @@ -6,11 +6,16 @@ import { getOffsetLT, getOffsetLTImproved, getActualNodeDimensions, isTopic } fr const ns = 'http://www.w3.org/2000/svg' function createSvgDom(height: string, width: string) { const svg = document.createElementNS(ns, 'svg') + const heightNum = parseFloat(height) + const widthNum = parseFloat(width) + setAttributes(svg, { version: '1.1', xmlns: ns, height, width, + viewBox: `0 0 ${widthNum} ${heightNum}`, // 添加viewBox确保坐标归一化 + preserveAspectRatio: 'xMidYMid meet' // 保持宽高比 }) return svg } @@ -27,7 +32,6 @@ function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: num } else { content = tpc.childNodes[0].textContent! } - const lines = content!.split('\n') // 计算行高和字体大小 const lineHeight = parseFloat(tpcStyle.lineHeight) || parseFloat(tpcStyle.fontSize) * 1.2 @@ -38,43 +42,23 @@ function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: num // 计算节点宽度和文本起始位置 const nodeWidth = tpc.offsetWidth || 200 const textX = x + paddingLeft // 纯文本节点使用左对齐 + const textY = y + paddingTop + fontSize + + // 使用tspan处理多行文本 + const textElement = createMultilineText(content, textX, textY, lineHeight, tpcStyle) // 计算实际需要的内容高度 + const lines = content.split('\n').filter(line => line.trim()) 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', - }) + // 使用统一背景管理创建背景矩形 + const bg = createBackground(x, y, nodeWidth, actualHeight, tpcStyle) g.appendChild(bg) + g.appendChild(textElement) - 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优化了底部间距:', { + console.log('✅ generateSvgText使用tspan优化多行文本:', { originalHeight: nodeHeight, actualHeight: actualHeight, linesCount: lines.length @@ -357,6 +341,59 @@ class TableToSVGConverter { } } +/** + * 将computedStyle转换为内联样式字符串 + */ +const computedStyleToInline = (element: HTMLElement, additionalStyles: Record = {}): string => { + const computedStyle = window.getComputedStyle(element) + const styleMap: Record = { + ...additionalStyles, + 'font-family': computedStyle.fontFamily, + 'font-size': computedStyle.fontSize, + 'font-weight': computedStyle.fontWeight, + 'color': computedStyle.color, + 'line-height': computedStyle.lineHeight, + 'text-align': computedStyle.textAlign, + 'padding': computedStyle.padding, + 'margin': computedStyle.margin, + 'border': computedStyle.border, + 'background-color': computedStyle.backgroundColor, + 'width': computedStyle.width, + 'height': computedStyle.height, + 'box-sizing': 'border-box', + 'overflow': 'hidden' + } + + return Object.entries(styleMap) + .filter(([_, value]) => value && value !== 'initial' && value !== 'inherit') + .map(([key, value]) => `${key}: ${value}`) + .join('; ') +} + +/** + * 使用tspan处理多行文本 + */ +const createMultilineText = (text: string, x: number, y: number, lineHeight: number, style: CSSStyleDeclaration): SVGGElement => { + const g = document.createElementNS(ns, 'g') + const lines = text.split('\n').filter(line => line.trim()) + + lines.forEach((line, index) => { + const tspan = document.createElementNS(ns, 'tspan') + setAttributes(tspan, { + x: x.toString(), + dy: index === 0 ? '0' : lineHeight.toString(), + 'font-family': style.fontFamily, + 'font-size': style.fontSize, + 'font-weight': style.fontWeight, + fill: style.color, + }) + tspan.textContent = line.trim() + g.appendChild(tspan) + }) + + return g +} + // 清理HTML内容,修复SVG解析错误 function cleanHtmlForSvg(html: string): string { if (!html) return html @@ -430,21 +467,13 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD if (hasTableContent) { console.log('🔄 检测到表格内容,使用foreignObject方式渲染HTML表格') - // 获取节点尺寸 - const nodeWidth = tpc.offsetWidth || 400 - const nodeHeight = tpc.offsetHeight || 200 + // 获取节点尺寸 - 使用getBoundingClientRect获取精确尺寸 + const rect = tpc.getBoundingClientRect() + const nodeWidth = Math.max(rect.width, tpc.offsetWidth || 400) + const nodeHeight = Math.max(rect.height, tpc.offsetHeight || 200) - // 创建背景矩形 - const bg = document.createElementNS(ns, 'rect') - setAttributes(bg, { - x: x + '', - y: y + '', - width: nodeWidth + '', - height: nodeHeight + '', - fill: 'white', - stroke: '#ccc', - 'stroke-width': '1' - }) + // 使用统一背景管理创建背景矩形 + const bg = createBackground(x, y, nodeWidth, nodeHeight, tpcStyle) g.appendChild(bg) // 创建foreignObject包含HTML表格 @@ -456,19 +485,17 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD height: nodeHeight + '' }) - // 创建HTML内容,确保XML语法正确 - const safeFontFamily = (tpcStyle.fontFamily || 'Arial, sans-serif').replace(/"/g, '"') + // 使用样式内联化确保样式正确显示 + const inlineStyles = computedStyleToInline(tpc, { + 'width': '100%', + 'height': '100%', + 'padding': '8px', + 'box-sizing': 'border-box', + 'overflow': 'hidden' + }) + const htmlContentForForeignObject = ` -
+
${cleanHtmlForSvg(htmlContent)}
` @@ -476,7 +503,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD foreignObject.innerHTML = htmlContentForForeignObject g.appendChild(foreignObject) - console.log('✅ 表格已使用foreignObject渲染') + console.log('✅ 表格已使用foreignObject渲染,样式已内联化') return g } @@ -780,6 +807,47 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD return g } +/** + * 统一背景管理函数 + * 消除重复创建,统一参数管理 + */ +const createBackground = ( + x: number, + y: number, + width: number, + height: number, + style: CSSStyleDeclaration, + additionalStyles: Record = {} +): SVGRectElement => { + const rect = document.createElementNS(ns, 'rect') + + const bgColor = additionalStyles.fill || style.backgroundColor || 'white' + const borderColor = additionalStyles.stroke || style.borderColor || '#ccc' + const borderWidth = additionalStyles['stroke-width'] || style.borderWidth || '1' + const borderRadius = additionalStyles.rx || style.borderRadius || '8' + + setAttributes(rect, { + x: x.toString(), + y: y.toString(), + width: width.toString(), + height: height.toString(), + fill: bgColor, + stroke: borderColor, + 'stroke-width': borderWidth, + rx: borderRadius, + ry: borderRadius, + ...additionalStyles + }) + + console.log('📦 创建统一背景:', { + position: { x, y }, + size: { width, height }, + styles: { bgColor, borderColor, borderWidth, borderRadius } + }) + + return rect +} + function createElBox(mei: MindElixirInstance, tpc: Topic) { // 不再创建背景rect,避免与convertDivToSvg重复创建 // 背景rect现在由convertDivToSvg统一管理 @@ -964,20 +1032,30 @@ async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Pr 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) + const rect = a.getBoundingClientRect() + const actualWidth = Math.min(rect.width || parseInt(aStyle.width) || 300, 300) + const actualHeight = Math.min(rect.height || parseInt(aStyle.height) || 200, 200) console.log('🖼️ 图片导出尺寸:', { computedWidth: aStyle.width, computedHeight: aStyle.height, + rectWidth: rect.width, + rectHeight: rect.height, actualWidth, actualHeight, imageUrl: imageUrl.substring(0, 50) }) + // 确保图片已加载完成 + await ensureImagesLoaded([a]) + // 尝试将图片转换为base64格式以确保导出时能正确显示 try { - const base64Url = await imageToBase64(imageUrl) + // 根据实际显示尺寸设置最大尺寸 + const maxWidth = Math.max(actualWidth * 2, 400) // 至少2倍分辨率 + const maxHeight = Math.max(actualHeight * 2, 300) + + const base64Url = await imageToBase64(imageUrl, maxWidth, maxHeight) setAttributes(svgI, { x: x + '', y: y + '', @@ -985,6 +1063,8 @@ async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Pr height: actualHeight + '', href: base64Url, }) + + console.log('✅ 图片已转换为base64格式') } catch (error) { console.warn('Failed to convert image to base64, using original URL:', error) setAttributes(svgI, { @@ -999,8 +1079,37 @@ async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Pr return svgI } -// 将图片URL转换为base64格式 -function imageToBase64(url: string): Promise { +/** + * 检测图片是否已加载完成 + */ +const ensureImagesLoaded = async (imgElements: HTMLImageElement[]): Promise => { + const loadPromises = imgElements.map(img => new Promise((resolve) => { + if (img.complete && img.naturalWidth > 0) { + resolve() + } else { + const onLoad = () => { + img.removeEventListener('load', onLoad) + img.removeEventListener('error', onError) + resolve() + } + const onError = () => { + img.removeEventListener('load', onLoad) + img.removeEventListener('error', onError) + console.warn('图片加载失败:', img.src) + resolve() // 即使失败也继续,避免阻塞导出 + } + img.addEventListener('load', onLoad) + img.addEventListener('error', onError) + } + })) + + await Promise.all(loadPromises) +} + +/** + * 将图片URL转换为base64格式(优化版) + */ +function imageToBase64(url: string, maxWidth: number = 800, maxHeight: number = 600): Promise { return new Promise((resolve, reject) => { const img = new Image() img.crossOrigin = 'anonymous' @@ -1008,8 +1117,6 @@ function imageToBase64(url: string): Promise { img.onload = () => { try { const canvas = document.createElement('canvas') - canvas.width = img.width - canvas.height = img.height const ctx = canvas.getContext('2d') if (!ctx) { @@ -1017,8 +1124,24 @@ function imageToBase64(url: string): Promise { return } - ctx.drawImage(img, 0, 0) - const base64 = canvas.toDataURL('image/png') + // 计算缩放后的尺寸,保持宽高比 + let { width, height } = img + if (width > maxWidth || height > maxHeight) { + const ratio = Math.min(maxWidth / width, maxHeight / height) + width = Math.floor(width * ratio) + height = Math.floor(height * ratio) + } + + canvas.width = width + canvas.height = height + + // 使用高质量缩放 + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + ctx.drawImage(img, 0, 0, width, height) + + // 使用JPEG格式减少文件大小,质量设为0.9 + const base64 = canvas.toDataURL('image/jpeg', 0.9) resolve(base64) } catch (error) { reject(error) @@ -1026,9 +1149,14 @@ function imageToBase64(url: string): Promise { } img.onerror = () => { - reject(new Error('Failed to load image')) + reject(new Error(`Failed to load image: ${url}`)) } + // 设置超时 + setTimeout(() => { + reject(new Error(`Image load timeout: ${url}`)) + }, 10000) // 10秒超时 + img.src = url }) } @@ -1038,50 +1166,145 @@ const padding = 100 const head = `` /** - * 获取元素相对于容器的绝对位置 - * 这是解决SVG导出坐标转换问题的关键函数 + * 解析CSS transform matrix获取缩放比例 + */ +const parseTransformMatrix = (transform: string): { scaleX: number, scaleY: number } => { + if (!transform || transform === 'none') { + return { scaleX: 1, scaleY: 1 } + } + + // 匹配matrix(a, b, c, d, e, f)格式 + const matrixMatch = transform.match(/matrix\(([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^)]+)\)/) + if (matrixMatch) { + const [, a, b, c, d] = matrixMatch.map(Number) + return { scaleX: a, scaleY: d } + } + + // 匹配scale(x, y)格式 + const scaleMatch = transform.match(/scale\(([^,]+)(?:,\s*([^)]+))?\)/) + if (scaleMatch) { + const scaleX = parseFloat(scaleMatch[1]) + const scaleY = parseFloat(scaleMatch[2] || scaleMatch[1]) + return { scaleX, scaleY } + } + + return { scaleX: 1, scaleY: 1 } +} + +/** + * 获取元素相对于容器的绝对位置(优化版) + * 解决滚动偏移、transform/scale、SVG坐标归一化问题 */ const getAbsolutePosition = (element: HTMLElement, container: HTMLElement) => { const elementRect = element.getBoundingClientRect() const containerRect = container.getBoundingClientRect() - // 考虑容器的transform和滚动 + // 1. 处理滚动偏移 + const scrollLeft = container.scrollLeft || 0 + const scrollTop = container.scrollTop || 0 + + // 2. 考虑容器的transform和缩放 const containerStyle = window.getComputedStyle(container) const containerTransform = containerStyle.transform + const { scaleX, scaleY } = parseTransformMatrix(containerTransform) + + // 3. 考虑元素的transform + const elementStyle = window.getComputedStyle(element) + const elementTransform = elementStyle.transform + const { scaleX: elementScaleX, scaleY: elementScaleY } = parseTransformMatrix(elementTransform) + + // 4. 计算基础位置(考虑滚动) + let x = elementRect.left - containerRect.left + scrollLeft + let y = elementRect.top - containerRect.top + scrollTop + + // 5. 应用缩放补偿 + if (scaleX !== 1 || scaleY !== 1) { + x = x / scaleX + y = y / scaleY + } + + if (elementScaleX !== 1 || elementScaleY !== 1) { + x = x / elementScaleX + y = y / elementScaleY + } + + // 6. 考虑容器的padding/margin + const containerPaddingLeft = parseInt(containerStyle.paddingLeft) || 0 + const containerPaddingTop = parseInt(containerStyle.paddingTop) || 0 + const containerMarginLeft = parseInt(containerStyle.marginLeft) || 0 + const containerMarginTop = parseInt(containerStyle.marginTop) || 0 + + x = x - containerPaddingLeft - containerMarginLeft + y = y - containerPaddingTop - containerMarginTop + + console.log('🔍 位置计算详情:', { + element: element.textContent?.substring(0, 30), + elementRect: { left: elementRect.left, top: elementRect.top, width: elementRect.width, height: elementRect.height }, + containerRect: { left: containerRect.left, top: containerRect.top }, + scroll: { left: scrollLeft, top: scrollTop }, + containerScale: { scaleX, scaleY }, + elementScale: { scaleX: elementScaleX, scaleY: elementScaleY }, + containerPadding: { left: containerPaddingLeft, top: containerPaddingTop }, + finalPosition: { x, y } + }) return { - x: elementRect.left - containerRect.left, - y: elementRect.top - containerRect.top, + x: Math.round(x * 100) / 100, // 保留2位小数,避免浮点精度问题 + y: Math.round(y * 100) / 100, width: elementRect.width, height: elementRect.height } } /** - * 确保所有内容已完全渲染 - * 特别是foreignObject内容 + * 确保所有内容已完全渲染(优化版) + * 包括图片加载、字体加载、DOM更新、foreignObject渲染 */ -const ensureContentRendered = async () => { +const ensureContentRendered = async (container: HTMLElement) => { + console.log('🔄 开始确保内容完全渲染...') + // 1. 等待Vue DOM更新 await new Promise(resolve => setTimeout(resolve, 0)) // 2. 等待字体加载 - await document.fonts.ready + if (document.fonts && document.fonts.ready) { + await document.fonts.ready + } // 3. 等待下一帧渲染 await new Promise(resolve => requestAnimationFrame(resolve)) - // 4. 额外等待foreignObject渲染 + // 4. 收集所有图片元素并等待加载完成 + const allImages = Array.from(container.querySelectorAll('img')) as HTMLImageElement[] + if (allImages.length > 0) { + console.log(`🖼️ 等待 ${allImages.length} 个图片加载完成...`) + await ensureImagesLoaded(allImages) + } + + // 5. 等待所有foreignObject内容渲染 + const foreignObjects = container.querySelectorAll('foreignObject') + if (foreignObjects.length > 0) { + console.log(`📄 等待 ${foreignObjects.length} 个foreignObject渲染完成...`) + await new Promise(resolve => setTimeout(resolve, 200)) + } + + // 6. 等待CSS动画和过渡完成 await new Promise(resolve => setTimeout(resolve, 100)) + + // 7. 最后等待一帧确保所有内容稳定 + await new Promise(resolve => requestAnimationFrame(resolve)) + + console.log('✅ 内容渲染同步完成') } const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) => { console.log('🎯 generateSvg 开始执行,noForeignObject:', noForeignObject) - // 确保所有内容已完全渲染,特别是foreignObject - await ensureContentRendered() - const mapDiv = mei.nodes + + // 确保所有内容已完全渲染,特别是foreignObject + await ensureContentRendered(mapDiv) + const mapDivRect = mapDiv.getBoundingClientRect() const height = mapDiv.offsetHeight + padding * 2