让 SVG 不再"丢字变形":一次思维导图导出文字转 Path 的实战优化
在做在线思维导图导出 SVG 时,我遇到了一个很隐蔽但很影响体验的问题:编辑器里看起来好好的文字,导出后换个环境打开就变样了。
前言
最近在做一个基于 Vue 3 + Vite 的在线思维导图项目:gimi-mind-map。
这个项目主要做的是在线编辑思维导图,支持节点编辑、主题样式、概要、外框、关系线、图片、XMind 导入导出,以及 PNG、JPEG、PDF、SVG 等多种格式导出。
项目信息:
- Git 仓库:
https://gitee.com/zheng_xuan520/x-mind-map.git - 在线访问地址:GimiMind思维导图
这篇文章主要聊其中一个具体问题:mind-map 导出 SVG 时,如何保证文字在不同环境中稳定显示。
如果你也做过类似的导出功能,大概率会见过这种场景:
- 编辑器里字体正常,导出的 SVG 打开后变成默认字体
- 节点文字原本自动换行,导出后变成一行撑出去
- 关系线文字位置偏移
- 概要、外框标题和节点文字对不上
- 浏览器里还好,放到设计软件、图片查看器、后端转图服务里就变形
这类问题很烦,因为它不是"文件打不开"这种明显错误。SVG 能正常打开,图形也都在,但文字慢慢偏了。对于思维导图来说,文字一偏,节点尺寸、分支重心、连接关系都会跟着变得不协调。
所以这次优化的目标很明确:
不是简单把 SVG 导出来,而是让导出的 SVG 尽量保持编辑器里的文字外观。
最终方案是:导出前把文字转换成 SVG Path。
先看结论
之前的实现方式是:导出 SVG 时在 SVG 内部注入 @font-face,让 SVG 打开时自己加载字体。
新的实现方式是:导出 SVG 前,使用 opentype.js 读取字体文件,把文字按当前布局转换成 <path>。
一句话对比:
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
@font-face |
SVG 保留文本,打开时加载字体 | 文件小、文字可编辑、实现简单 | 依赖字体加载,跨环境容易变形 |
| Text to Path | 导出前把文字轮廓固化成 path | 显示稳定、跨环境一致性更好 | 文件变大、文字不可编辑、导出更重 |
如果导出的 SVG 主要用于再次编辑文字,旧方案更友好。
如果导出的 SVG 主要用于分享、预览、存档、嵌入文档、后端转 PNG/PDF,新方案更可靠。
问题是怎么出现的?
思维导图的 SVG 不是一张简单图片,它里面有很多文字场景:
- 普通节点文字
- 概要文字
- 节点序号
- 外框标题
- 关系线文字
这些文字在编辑器里会受到很多因素影响:
font-familyfont-sizefont-weightfont-styleline-height- 节点宽度
- 自动换行
- transform 缩放和平移
foreignObject中的 HTML 排版- SVG 原生
<text>的字符定位
在浏览器里,布局引擎会把这些东西算好,所以我们看到的是正确画面。
但导出 SVG 后,打开方不一定拥有同样的字体,也不一定支持同样的 SVG 特性。尤其是 foreignObject 和 @font-face,在不同环境中的支持并不完全一致。
于是问题就来了:SVG 文件保存的是"文字 + 样式",但最终如何显示,要靠打开环境重新解释。
只要字体加载失败,或者换了一个字体,文字宽度就会变。文字宽度一变,换行和截断也会变。换行一变,节点高度和视觉位置就可能不一致。
旧方案:在 SVG 里注入 @font-face
旧方案的核心做法是:导出 SVG 时,向 SVG 里插入一段 <defs><style>...</style></defs>,声明项目里用到的字体。
大致是这样的:
xml
<defs>
<style>
@font-face {
font-family: 'nevermind';
src: url('https://xxx/fonts/NeverMind-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'allseto';
src: url('https://xxx/fonts/cjkFonts-allseto.ttf') format('truetype');
}
</style>
</defs>
然后 SVG 里的文字仍然保持文本形态:
xml
<text font-family="nevermind">中心主题</text>
或者保留在 foreignObject 中:
xml
<foreignObject>
<div class="node-text-description">中心主题</div>
</foreignObject>
这个方案的思路很自然:
我不改变文字本身,只把文字依赖的字体一起告诉 SVG。
旧方案的优点
它确实有一些很实际的优点:
- 实现成本低
- SVG 文件体积较小
- 文字仍然是文字,可以复制、搜索,甚至二次编辑
- 在普通浏览器里打开时,体验通常不错
如果你的导出场景只面向现代浏览器,而且字体资源稳定可访问,这个方案是可以接受的。
旧方案的缺点
但它的问题也很明显:
- 依赖外部字体资源
- 离线环境可能直接丢字体
- 字体链接失效后 SVG 会变样
- 不同软件对
@font-face支持不一致 foreignObject在部分 SVG 渲染环境里兼容性不稳定- 字体替换后,文字宽度、换行、截断都可能变化
对思维导图这种强排版场景来说,最后一点尤其致命。
一个节点里的文字多换一行,可能整个分支的视觉重心都变了。
新方案:导出前把文字转成 Path
新方案的核心思路是:
不再让 SVG 打开时去找字体,而是在导出时就把文字画成图形。
也就是说,导出前把:
xml
<text font-family="nevermind">中心主题</text>
转换成:
xml
<g class="export-text-path">
<path d="M10 20 C..." fill="#000" />
</g>
文字变成 path 后,打开 SVG 的环境就不需要知道它原来是什么字体了。它只需要绘制几何路径。
这样导出的 SVG 在浏览器、设计软件、图片查看器、后端转图服务中都会更稳定。
导出流程发生了什么?
SVG 导出入口里会先 clone 当前画布:
js
const cloneSvg = svg.clone(true)
为什么要 clone?
因为当前画布是用户正在编辑的真实 DOM,如果直接把文字替换成 path,编辑器里的文字就没法继续编辑了。
所以导出流程只处理克隆出来的 SVG:
js
await convertExportTextToPath(cloneSvg.node())
这一步发生在:
- SVG 被克隆之后
- 背景矩形插入之后
- 导出尺寸重新计算之后
outerHTML序列化之前
也就是说,用户界面里的文字还是文字,只有导出的那份 SVG 被固化成 path。
在调用转换前,导出逻辑还会做一些准备:
js
cloneSvg
.attr('width', cwidth + spacing * 2)
.attr('height', cheight + spacing * 2)
.selectAll('.node-text-description, .node-summary-description')
.style('white-space', 'pre-wrap')
.style('line-height', 'inherit')
.style('padding', 0)
.style('margin', 0)
这些处理的目的,是让导出时读取到的文本布局尽量贴近最终导出画面。
textToPath.js 的整体结构
这个文件可以拆成几层来看:
- 字体映射和字体加载
- 字体名解析
- 字形缺失检查
- HTML 文本位置测量
- 坐标转换
- path 生成
- 粗体、斜体、下划线等样式补偿
- SVG 原生
<text>处理 - 总入口统一调度
整条流水线可以概括成:
txt
选择文字元素
-> 解析字体
-> 检查字形
-> 读取浏览器布局结果
-> 换算 SVG 坐标
-> 生成 path
-> 补偿文本样式
-> 替换原节点
下面具体展开。
1. 字体映射:只转换项目可控字体
文件顶部维护了一份字体映射:
js
const FONT_URLS = {
audiowide: '/static/font-family/Audiowide-Regular.ttf',
allseto: '/static/font-family/cjkFonts-allseto.ttf',
nevermind: '/static/font-family/NeverMind-Regular.ttf',
nevermindhand: '/static/font-family/NeverMindHand-Regular.ttf',
arvo: '/static/font-family/Arvo.ttf',
lato: '/static/font-family/lato.woff2',
consola: '/static/font-family/consola.ttf',
bagnard: '/static/font-family/Bagnard-2.otf',
pangmenzhengdaobiaotiti: '/static/font-family/PangMenZhengDaoBiaoTiTi.ttf',
pangmenzhengdaobiaotitiys: '/static/font-family/PangMenZhengDaoBiaoTiTiYS.ttf'
}
这里有一个非常关键的取舍:
只转换项目内置字体,不尝试读取用户系统字体。
原因很简单:浏览器可以通过 CSS 使用系统字体,但 JavaScript 不能直接读取用户电脑里的字体文件。
如果一个字体不在 FONT_URLS 中,代码就不会强行转换。否则生成出来的 path 可能和浏览器真实渲染的字体不一致,反而更危险。
2. 选择要转换的文字
代码中分了两类选择器。
第一类是 foreignObject 里的 HTML 文本:
js
const TEXT_SELECTOR = [
'.node-text-description',
'.node-summary-description',
'.node-serial-numner p',
'.out-border-desc-input'
].join(',')
对应场景:
- 节点正文
- 概要文字
- 节点序号
- 外框描述
第二类是 SVG 原生 <text>:
js
const SVG_TEXT_SELECTOR = [
'.mind-map-relationbox text'
].join(',')
当前主要用于关系线文字。
为什么要分开?
因为 foreignObject 中的文字是 HTML 排版,要用 Range 去读真实布局;SVG <text> 有自己的字符定位 API,要用 getStartPositionOfChar 这类方法。
3. 字体加载:用 opentype.js 解析字体文件
字体加载函数是:
js
async function loadFont (fontFamily) {
const fontName = normalizeFontName(fontFamily)
if (!fontName) return null
if (!fontCache.has(fontName)) {
fontCache.set(fontName, fetch(FONT_URLS[fontName]).then(res => {
if (!res.ok) {
throw new Error(`load font failed: ${FONT_URLS[fontName]}`)
}
return res.arrayBuffer()
}).then(buffer => opentype.parse(buffer)))
}
return {
fontName,
font: await fontCache.get(fontName)
}
}
这里做了几件事:
- 从 CSS
font-family中找到项目可识别字体 - 通过
fetch加载字体文件 - 转成
arrayBuffer - 用
opentype.parse解析字体 - 缓存解析结果
缓存很重要:
js
const fontCache = new Map()
一个导图里可能有几十上百个节点,如果每个节点都重新加载字体,导出会非常慢。这里缓存的是 Promise,所以并发场景下同一个字体也只会发起一次加载。
4. 字体名解析:处理 CSS 字体栈
浏览器里的 font-family 经常不是一个值:
css
font-family: "nevermind", Arial, sans-serif;
所以代码先做标准化:
js
function parseFontFamilyNames (fontFamily = '') {
return String(fontFamily || '')
.split(',')
.map(item => item.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
.filter(Boolean)
}
这个函数会:
- 按逗号拆分
- 去掉空格
- 去掉引号
- 转小写
- 过滤空值
比如:
js
'"NeverMind", Arial, sans-serif'
会变成:
js
['nevermind', 'arial', 'sans-serif']
然后 getFontStack 会筛出项目内能识别的字体:
js
function getFontStack (fontFamily = '') {
return Array.from(new Set(parseFontFamilyNames(fontFamily).filter(item => FONT_URLS[item])))
}
这样就能支持一个基本的字体 fallback:如果第一个字体缺某个字符,就尝试后面的可控字体。
5. 缺字检查:宁可保留文本,也不生成错误 path
字体文件不一定包含所有字符。比如某个英文字体可能没有中文,某个中文字体可能没有特殊符号。
代码用 hasGlyph 判断字形是否有效:
js
function hasGlyph (glyph) {
return glyph?.index !== 0 && glyph?.name !== '.notdef'
}
很多字体缺字时会返回 .notdef,也就是缺字占位符。如果不判断,导出的 SVG 里可能出现方块、空框,或者错误图形。
getGlyphInfo 会在字体栈里找能显示当前字符的字体:
js
function getGlyphInfo (fontStack, char) {
for (const item of fontStack) {
const glyph = item.font.charToGlyph(char)
if (char === ' ' || hasGlyph(glyph)) {
return {
...item,
glyph
}
}
}
return null
}
注意这里对空格做了特殊处理。
空格没有可见轮廓,但它是排版的一部分。它不需要生成 path,却不能被当成错误字符。
如果发现缺字,代码会跳过这段文字的转换:
js
element.setAttribute('data-export-text-path-skipped', 'missing-glyph')
element.setAttribute('data-export-text-path-missing-chars', missingChars.join(''))
这个策略很稳。
因为错误转换比不转换更糟。保留原文本,至少还有浏览器文本渲染兜底;强行生成错误 path,用户看到的可能就是缺字方块。
6. 最关键的一步:读取浏览器真实排版
节点文字一般在 foreignObject 里,本质上是 HTML 文本。
HTML 文本的换行、行高、截断,不应该自己手写算法去猜。浏览器已经把它算好了,我们要做的是把结果读出来。
代码先找到真实文本节点:
js
function getTextContentNode (element) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
while (node && !node.nodeValue) {
node = walker.nextNode()
}
return node
}
然后逐字符创建 Range:
js
range.setStart(textNode, index)
range.setEnd(textNode, index + 1)
const rect = range.getClientRects()[0]
这一步非常妙。
它不是根据字符串长度估算位置,而是直接拿浏览器真实布局后的字符矩形。
每个字符会得到:
lefttoprightbottomwidthheight
这样自动换行、手动换行、CSS 行高、节点宽度造成的布局变化,都可以被读出来。
对于换行符,代码单独标记:
js
if (char === '\n') {
chars.push({ char, isLineBreak: true })
continue
}
也就是说,新方案不是"重新实现一个排版引擎",而是:
让浏览器先排版,然后把浏览器排好的结果描下来。
7. 行分组:把字符按真实行重新组织
拿到字符矩形后,需要知道哪些字符属于同一行。
代码通过 rect.top 判断:
js
if (prev && Math.abs(prev.rect.top - item.rect.top) > lineThreshold) {
lines.push(currentLine)
currentLine = []
}
这里的 lineThreshold = 3 是容差值。
浏览器布局中可能出现亚像素误差,如果完全要求 top 一样,很容易把同一行误判成多行。
最终得到的结构大概是:
js
[
[char1, char2, char3],
[char4, char5],
[char6, char7, char8]
]
后续每一行都会单独计算 baseline,单独生成 path,也能单独绘制下划线和删除线。
8. 坐标转换:屏幕坐标不能直接写进 SVG
Range.getClientRects() 拿到的是浏览器视口坐标,也就是屏幕坐标。
但 SVG path 需要的是 SVG 内部坐标。
如果直接用屏幕坐标,导出的文字 path 很可能会偏移到错误位置。尤其是画布本身有缩放、平移、分组 transform 时,这个问题会非常明显。
代码使用 getScreenCTM() 做转换:
js
function screenPointToLocalPoint (coordinateElement, x, y) {
const matrix = coordinateElement.getScreenCTM()
if (!matrix) return { x, y }
const svgElement = coordinateElement.ownerSVGElement || coordinateElement
const point = svgElement.createSVGPoint()
point.x = x
point.y = y
return point.matrixTransform(matrix.inverse())
}
getScreenCTM() 可以拿到当前 SVG 元素到屏幕坐标的变换矩阵。
取它的逆矩阵,就能把屏幕坐标反推回 SVG 本地坐标。
这一步保证了生成的 path 还能落在原来的文字位置上。
9. Baseline:为什么不能直接用字符顶部
字体绘制不是从矩形顶部开始的,而是围绕 baseline 绘制。
opentype.js 的 API 是:
js
font.getPath(char, x, y, fontSize)
这里的 y 是基线位置,不是字符顶部。
所以代码要计算每一行的 baseline:
js
const fontSize = getNumericStyle(style, 'font-size', 14)
const lineHeightStyle = style.getPropertyValue('line-height')
const lineHeight = lineHeightStyle === 'normal'
? fontSize * 1.2
: getNumericStyle(style, 'line-height', fontSize * 1.2)
const ascender = font.ascender / font.unitsPerEm * fontSize
const baselineOffset = Math.max(ascender, (lineHeight - fontSize) / 2 + ascender)
这里用到了字体的:
ascenderunitsPerEm
简单理解:
ascender表示字体向上伸展的高度unitsPerEm用来把字体内部单位换算成 CSS 像素
最后通过:
js
screenPointToLocalPoint(coordinateElement, firstRect.left, firstRect.top + baselineOffset)
得到当前行的 SVG 本地基线坐标。
10. 生成 path:真正把文字变成图形
核心转换发生在这里:
js
const charPath = font.getPath(item.char, charPoint.x, baseline.y, fontSize)
linePath += charPath.toPathData(2)
每个字符都会根据字体轮廓生成 path,然后拼接成当前行的完整路径。
toPathData(2) 表示路径数据保留 2 位小数。
这是一个体积和精度之间的折中:
- 小数位太多,SVG 文件变大
- 小数位太少,文字轮廓可能粗糙或位置不准
最后创建一个新的分组:
xml
<g class="export-text-path" data-source-class="node-text-description">
<path d="..." fill="..." />
</g>
然后替换原来的 foreignObject:
js
foreignObject.parentNode.insertBefore(group, foreignObject)
foreignObject.remove()
到这里,HTML 文本就真正被 SVG path 替代了。
11. 粗体怎么处理?
文字变成 path 后,font-weight 不会再自动生效。
所以代码做了一个合成粗体:
js
function isBoldFontWeight (fontWeight) {
const weight = parseInt(fontWeight, 10)
return fontWeight === 'bold' || fontWeight === 'bolder' || (Number.isFinite(weight) && weight >= 600)
}
如果检测到粗体,就给 path 加同色描边:
js
pathNode.setAttribute('stroke', fill)
pathNode.setAttribute('stroke-width', boldStrokeWidth)
pathNode.setAttribute('stroke-linejoin', 'round')
pathNode.setAttribute('paint-order', 'stroke fill')
stroke-width 会根据字号和字体粗细计算:
js
return Math.max(0.45, fontSize * 0.035 * weightRatio)
这不是严格意义上的真实 Bold 字体,但在没有完整字体族文件的情况下,是一个比较实用的视觉补偿。
12. 斜体怎么处理?
斜体也是同样的问题。
文字变成 path 后,font-style: italic 不会再自动改变字形。
代码会判断:
js
function isItalicFontStyle (fontStyle) {
return ['italic', 'oblique'].includes(String(fontStyle || '').toLowerCase())
}
如果是斜体,就给 path 添加矩阵变换:
js
const skew = -0.22
return `matrix(1 0 ${skew} 1 ${-skew * baselineY} 0)`
本质上是做一个水平倾斜,让普通字形视觉上接近斜体。
这里还用 baselineY 做了偏移补偿,减少倾斜后整体位置漂移。
13. 下划线和删除线怎么处理?
text-decoration 对 path 也不会生效。
所以代码直接额外画线:
js
lineNode.setAttribute('d', `M${start.x} ${start.y} L${end.x} ${end.y}`)
lineNode.setAttribute('fill', 'none')
lineNode.setAttribute('stroke', fill)
lineNode.setAttribute('stroke-width', Math.max(1, fontSize / 16))
lineNode.setAttribute('stroke-linecap', 'round')
下划线位置:
js
const y = line[0].rect.bottom + fontSize * 0.08
删除线位置:
js
const y = line[0].rect.top + line[0].rect.height * 0.55
然后再把线段的起点和终点从屏幕坐标转换成 SVG 本地坐标。
这样即使文字已经不再是文本,下划线和删除线也能保留下来。
14. SVG 原生 <text> 怎么处理?
关系线文字通常是 SVG 原生 <text>,不是 foreignObject。
这类文字不能用 HTML Range 获取布局,但 SVG 提供了字符定位 API:
js
textElement.getNumberOfChars()
textElement.getStartPositionOfChar(index)
textElement.getEndPositionOfChar(index)
转换时直接拿每个字符的 SVG 坐标:
js
const position = getSvgCharPosition(textElement, index)
pathData += font.getPath(char, position.x, position.y, fontSize).toPathData(2)
这里的 position.y 已经是 SVG text 的基线位置,所以不需要像 HTML 文本那样再计算 baseline。
转换完成后,同样插入 path 分组,然后删除原 <text>:
js
textElement.parentNode.insertBefore(group, textElement)
textElement.remove()
15. 总入口:分批转换 + 容错
整个文件只向外暴露一个函数:
js
export async function convertExportTextToPath (svgElement)
它先处理 foreignObject 文本:
js
const elements = Array.from(svgElement.querySelectorAll(TEXT_SELECTOR))
for (const element of elements) {
try {
const beforeForeignObject = element.closest('foreignObject')
await convertElementToPath(svgElement, element)
if (beforeForeignObject && !beforeForeignObject.isConnected) {
convertedCount += 1
}
} catch (error) {
console.warn('convert text to path failed:', error)
}
}
再处理 SVG 原生 <text>:
js
const svgTextElements = Array.from(svgElement.querySelectorAll(SVG_TEXT_SELECTOR))
for (const textElement of svgTextElements) {
try {
await convertSvgTextToPath(textElement)
} catch (error) {
console.warn('convert svg text to path failed:', error)
}
}
这里有两个细节值得注意。
第一,先处理 HTML 文本,再处理 SVG text,避免节点替换时互相影响。
第二,每个元素单独 try/catch。某一个节点转换失败,不会导致整个 SVG 导出失败。
最后还有一个残留检查:
js
const remains = svgElement.querySelectorAll(TEXT_SELECTOR).length
const svgTextRemains = svgElement.querySelectorAll(SVG_TEXT_SELECTOR).length
if (remains || svgTextRemains) {
console.warn(`convert text to path incomplete: ...`)
}
这个 warning 主要给开发调试用,方便发现哪些文本因为字体缺失、字形缺失或异常情况没有转换成功。
新方案的优点
整体看下来,新方案的优点很明显:
- 不依赖打开环境是否安装字体
- 不依赖 SVG 里的
@font-face是否被支持 - 跨浏览器、图片查看器、设计软件显示更稳定
- 后端转 PNG/PDF 时更不容易丢字体
- 换行和行高基于浏览器真实布局读取,更接近所见即所得
- 粗体、斜体、下划线、删除线都有补偿处理
- 对思维导图这种强排版图形更友好
尤其是最后一点。
思维导图不是一段普通文本。它的文字和节点、连线、概要、外框是绑定在一起的。文字变了,图的结构感就变了。
新方案的缺点
当然,Text to Path 也不是银弹。
它的代价主要有几个:
- SVG 文件会变大
- 文字不可复制
- 文字不可编辑
- 导出耗时会增加
- 只适合项目可控字体
- 缺字时仍然需要保留原文本兜底
- 粗体、斜体是视觉模拟,不是真正的字体族切换
所以这不是一个"永远更好"的方案,而是一个取舍。
如果你的 SVG 导出目标是让用户继续编辑文字,那么保留 <text> 更合适。
如果你的 SVG 导出目标是稳定展示、跨端预览、归档、转图片、转 PDF,那么文字转 path 会更可靠。
再对比一下两种方案
| 对比项 | @font-face 方案 |
文字转 Path 方案 |
|---|---|---|
| 核心思路 | 保留文本,声明字体 | 把文字轮廓固化成 path |
| 字体依赖 | 依赖字体资源加载 | 不依赖打开环境字体 |
| 显示稳定性 | 受环境影响较大 | 更稳定 |
| SVG 体积 | 较小 | 较大 |
| 文字复制 | 支持 | 不支持 |
| 文字编辑 | 更友好 | 基本不可编辑 |
| 导出性能 | 更快 | 更慢 |
| 跨软件兼容 | 不稳定 | 更稳定 |
| 换行还原 | 字体变化后可能错位 | 基于真实布局转换 |
| 样式处理 | 依赖文本 CSS | 需要手动补偿 |
最后
这次优化给我的感受是:SVG 导出里的文字问题,不能只看"能不能导出",还要看"导出后在哪里打开"。
旧方案把字体问题留给了 SVG 的打开环境,新方案则在导出时就把文字外观固化下来。
这背后的思路其实很简单:
对编辑器来说,文字应该是可编辑的;对导出产物来说,外观应该是稳定的。
所以当前项目选择了在导出阶段把文字转成 path。它牺牲了一部分可编辑性和文件体积,但换来了更好的跨环境一致性。
对于思维导图这种图形和文字高度绑定的场景,这个取舍是值得的。
如果你也在做 SVG 导出、海报生成、图形编辑器、白板、流程图、拓扑图之类的功能,文字转 path 这个方案也许能帮你少踩很多字体和排版的坑。