优化SVG导出功能

- 改进位置计算:处理滚动偏移、transform/scale、SVG坐标归一化
- 优化HTML内容渲染:样式内联化、限制宽高、文字换行处理
- 增强图片处理:统一Base64转换、异步加载管理、大小控制
- 统一背景管理:消除重复创建、统一参数管理
- 完善异步渲染同步:Promise+图片加载检测、整体渲染等待

解决了SVG导出中的位置偏移、内容缺失、图片显示等问题
This commit is contained in:
lixinran 2025-10-15 15:24:08 +08:00
parent e07da11606
commit 8b8ce52e3a
1 changed files with 303 additions and 80 deletions

View File

@ -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, string> = {}): string => {
const computedStyle = window.getComputedStyle(element)
const styleMap: Record<string, string> = {
...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, '&quot;')
// 使用样式内联化确保样式正确显示
const inlineStyles = computedStyleToInline(tpc, {
'width': '100%',
'height': '100%',
'padding': '8px',
'box-sizing': 'border-box',
'overflow': 'hidden'
})
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;
">
<div xmlns="http://www.w3.org/1999/xhtml" style="${inlineStyles}">
${cleanHtmlForSvg(htmlContent)}
</div>
`
@ -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<string, string> = {}
): 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<string> {
/**
*
*/
const ensureImagesLoaded = async (imgElements: HTMLImageElement[]): Promise<void> => {
const loadPromises = imgElements.map(img => new Promise<void>((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<string> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
@ -1008,8 +1117,6 @@ function imageToBase64(url: string): Promise<string> {
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<string> {
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<string> {
}
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 = `<?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">`
/**
*
* 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/scaleSVG坐标归一化问题
*/
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. 等待字体加载
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