让 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思维导图
本文重点解决一个实际工程问题:思维导图导出 SVG 时,如何保证文字在任意环境下显示一致、不丢字、不变形。
做过同类导出需求的开发者,基本都踩过这些坑:
- 编辑器字体正常,导出 SVG 打开后变成系统默认字体
- 节点文字原本自动换行,导出后直接一行撑出边界
- 关系线附带文字位置偏移
- 概要、外框标题和节点文字排版对不上
- 浏览器预览正常,放到设计软件、图片查看器、后端转图服务就彻底变形
这类问题很隐蔽:SVG 能正常打开、图形都在,只是文字排版悄悄跑偏。思维导图本身强依赖文字布局,文字一乱,节点尺寸、分支重心、连线关系都会跟着不协调。
本次优化目标很明确:
不只是把 SVG 导出来,而是让导出结果和编辑器视觉完全一致,跨环境稳定不变形。
最终落地最优方案:导出前把所有文字统一转换成 SVG Path 路径。
先看结论
旧实现:导出 SVG 内部注入@font-face,依靠 SVG 打开时自动加载字体渲染文本。
新实现:导出阶段借助opentype.js读取字体文件,按照浏览器真实排版布局,把文字直接转成<path>固化。
两种方案直观对比:
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
@font-face | SVG 保留文本,打开时动态加载字体 | 文件体积小、文字可复制编辑、实现简单 | 依赖外部字体资源,跨环境极易排版变形 |
| Text to Path | 导出前将文字轮廓固化为 path 图形 | 跨环境显示稳定、排版零偏差、兼容性强 | 文件体积变大、文字不可编辑、导出开销略高 |
如果导出场景需要保留文字二次编辑,旧方案更友好;如果用于分享预览、存档嵌入、后端转 PNG/PDF,文字转 Path 方案更可靠。
问题是怎么出现的?
思维导图 SVG 包含多种文字场景:
- 普通节点文字
- 概要备注文字
- 节点序号
- 外框标题
- 关系线说明文字
这些文字在页面渲染时,会受大量样式与布局因素影响:font-family、font-size、font-weight、font-style、行高、节点宽度、自动换行、transform 缩放平移、foreignObject内部 HTML 排版、SVG 原生<text>字符定位。
浏览器有完整布局引擎,能精准计算所有规则,所以编辑界面显示完全正常。但导出 SVG 后,外部环境不一定预装对应字体,也不一定完整兼容foreignObject和@font-face特性。
核心根源:SVG 只保存文字内容和样式规则,最终渲染效果由打开环境重新解析。
只要字体加载失败或被替换,文字宽度就会改变,连带换行、节点高度、整体布局全部跑偏。
旧方案:在 SVG 里注入@font-face
旧方案核心思路:导出 SVG 时,在内部<defs>中注入字体样式声明。
<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>文字依旧保留原生文本形态:
<textfont-family="nevermind">中心主题</text>或是放在foreignObject内部渲染:
<foreignObject><divclass="node-text-description">中心主题</div></foreignObject>逻辑很直白:不改动文字本身,把依赖的字体地址一并声明给 SVG。
旧方案优点
- 开发实现成本低
- SVG 文件体积小巧
- 文字可复制、可搜索、支持二次编辑
- 现代浏览器内打开体验较好
旧方案缺点
- 强依赖外部字体资源可访问
- 离线环境直接丢失字体样式
- 字体链接失效后排版彻底错乱
- 不同设计软件、阅读器对
@font-face支持参差不齐 foreignObject在部分渲染环境兼容性差- 字体一旦替换,文字宽度、换行、节点高度全部连锁变化
对思维导图这种强排版业务来说,这一点是致命短板。
新方案:导出前把文字转成 Path
新方案核心思路:
不再依赖外部环境解析字体,导出时直接把文字绘制成几何图形。
把原生文本:
<textfont-family="nevermind">中心主题</text>直接转换为路径图形:
<gclass="export-text-path"><pathd="M10 20 C..."fill="#000"/></g>文字固化为 Path 后,任何环境只需渲染矢量路径,不再依赖字体文件,从根源解决跨环境变形问题。
导出流程发生了什么?
导出入口会先克隆当前画布 SVG,不污染正在编辑的真实 DOM:
constcloneSvg=svg.clone(true)只对克隆后的副本做文字转换处理:
awaitconvertExportTextToPath(cloneSvg.node())执行时机:
- SVG 克隆完成之后
- 背景矩形插入之后
- 导出尺寸重计算之后
- 序列化为 outerHTML 之前
保证编辑界面文字正常可编辑,仅导出产物做固化处理。
预处理样式统一排版:
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 文本真实布局测量
- 屏幕坐标转 SVG 局部坐标
- 逐字符生成 Path 路径
- 粗体/斜体/下划线样式视觉补偿
- SVG 原生
<text>兼容处理
执行流水线:
选择文字元素 → 解析匹配字体 → 校验字形完整性 → 读取浏览器真实布局 → 坐标矩阵转换 → 生成矢量路径 → 样式补偿 → 替换原有文本节点
1. 字体映射:只转换项目可控字体
内部维护内置字体映射表:
constFONT_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'}关键设计:只转换项目内置可控字体,不读取用户系统字体。
受浏览器安全限制,JS 无法直接读取本地系统字体文件,强行转换会造成渲染不一致。
2. 选择要转换的文字
分两类场景分别处理:
foreignObject内部 HTML 文本选择器:
constTEXT_SELECTOR=['.node-text-description','.node-summary-description','.node-serial-numner p','.out-border-desc-input'].join(',')覆盖:节点正文、概要文字、节点序号、外框描述。
SVG 原生<text>选择器:
constSVG_TEXT_SELECTOR=['.mind-map-relationbox text'].join(',')主要用于关系线文字。
分开处理原因:HTML 文本靠 Range 读布局,SVG Text 靠原生字符位置 API,两套逻辑不通用。
3. 字体加载:用opentype.js解析字体文件
asyncfunctionloadFont(fontFamily){constfontName=normalizeFontName(fontFamily)if(!fontName)returnnullif(!fontCache.has(fontName)){fontCache.set(fontName,fetch(FONT_URLS[fontName]).then(res=>{if(!res.ok){thrownewError(`load font failed:${FONT_URLS[fontName]}`)}returnres.arrayBuffer()}).then(buffer=>opentype.parse(buffer)))}return{fontName,font:awaitfontCache.get(fontName)}}核心逻辑:
- 标准化字体名匹配映射表
- Fetch 加载字体转 ArrayBuffer
- opentype.js 解析字体结构
- 全局 Promise 缓存,避免重复请求加载
4. 字体名解析:处理 CSS 字体栈
functionparseFontFamilyNames(fontFamily=''){returnString(fontFamily||'').split(',').map(item=>item.trim().replace(/^['"]|['"]$/g,'').toLowerCase()).filter(Boolean)}处理带引号、多字体 fallback 场景,清洗后筛选出项目支持的可控字体,实现字体降级兼容。
5. 缺字检查:宁可保留文本,不生成错误 path
functionhasGlyph(glyph){returnglyph?.index!==0&&glyph?.name!=='.notdef'}很多字体缺少中文或特殊符号时,会返回.notdef缺字占位符。一旦检测到缺字,直接跳过转换,保留原文本兜底,避免出现方块乱码。
functiongetGlyphInfo(fontStack,char){for(constitemoffontStack){constglyph=item.font.charToGlyph(char)if(char===' '||hasGlyph(glyph)){return{...item,glyph}}}returnnull}对空格特殊放行,不生成路径但保留排版占位。
6. 读取浏览器真实排版
不自己手写换行算法,直接复用浏览器已渲染好的布局结果。
获取文本节点:
functiongetTextContentNode(element){constwalker=document.createTreeWalker(element,NodeFilter.SHOW_TEXT)letnode=walker.nextNode()while(node&&!node.nodeValue){node=walker.nextNode()}returnnode}逐字符 Range 测距:
range.setStart(textNode,index)range.setEnd(textNode,index+1)constrect=range.getClientRects()[0]精准拿到每个字符的left/top/right/bottom,自动换行、行高、节点宽度带来的布局全部原生还原。
7. 行分组:按真实行重组字符
通过字符顶部坐标做行划分,设置微小容差规避亚像素精度误差:
if(prev&&Math.abs(prev.rect.top-item.rect.top)>lineThreshold){lines.push(currentLine)currentLine=[]}最终按行分组,便于逐行计算基线、生成路径、绘制装饰线。
8. 坐标转换:屏幕坐标转 SVG 局部坐标
Range 拿到的是浏览器视口屏幕坐标,不能直接用于 SVG 渲染。
通过矩阵逆变换做坐标换算:
functionscreenPointToLocalPoint(coordinateElement,x,y){constmatrix=coordinateElement.getScreenCTM()if(!matrix)return{x,y}constsvgElement=coordinateElement.ownerSVGElement||coordinateElementconstpoint=svgElement.createSVGPoint()point.x=x point.y=yreturnpoint.matrixTransform(matrix.inverse())}解决画布缩放、平移、嵌套 transform 带来的文字偏移问题。
9. Baseline 基线计算
opentype.js 绘制文字以基线为基准,不是字符顶部,必须手动换算:
constfontSize=getNumericStyle(style,'font-size',14)constlineHeightStyle=style.getPropertyValue('line-height')constlineHeight=lineHeightStyle==='normal'?fontSize*1.2:getNumericStyle(style,'line-height',fontSize*1.2)constascender=font.ascender/font.unitsPerEm*fontSizeconstbaselineOffset=Math.max(ascender,(lineHeight-fontSize)/2+ascender)依托字体ascender、unitsPerEm做单位换算,精准定位每一行基线位置。
10. 生成 path:文字转为矢量路径
constcharPath=font.getPath(item.char,charPoint.x,baseline.y,fontSize)linePath+=charPath.toPathData(2)逐字符生成路径并拼接,保留两位小数,平衡文件体积和渲染精度。
最后新建分组替换原 DOM:
<gclass="export-text-path"data-source-class="node-text-description"><pathd="..."fill="..."/></g>11. 粗体视觉补偿
转 Path 后font-weight失效,通过描边模拟粗体:
functionisBoldFontWeight(fontWeight){constweight=parseInt(fontWeight,10)returnfontWeight==='bold'||fontWeight==='bolder'||(Number.isFinite(weight)&&weight>=600)}pathNode.setAttribute('stroke',fill)pathNode.setAttribute('stroke-width',boldStrokeWidth)pathNode.setAttribute('stroke-linejoin','round')pathNode.setAttribute('paint-order','stroke fill')12. 斜体视觉补偿
通过矩阵倾斜模拟 italic 效果:
functionisItalicFontStyle(fontStyle){return['italic','oblique'].includes(String(fontStyle||'').toLowerCase())}constskew=-0.22return`matrix(1 0${skew}1${-skew*baselineY}0)`13. 下划线和删除线补偿
Path 无法继承text-decoration,手动绘制直线还原样式:
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')按行高比例定位下划线、删除线垂直位置,再做坐标转换对齐文字。
14. SVG 原生<text>处理
关系线文字使用 SVG 原生字符位置 API:
textElement.getNumberOfChars()textElement.getStartPositionOfChar(index)textElement.getEndPositionOfChar(index)直接获取基线坐标生成路径,逻辑同 HTML 文本。
15. 总入口:分批转换 + 容错机制
统一对外入口:
exportasyncfunctionconvertExportTextToPath(svgElement)先批量处理foreignObject文本,再处理 SVG 原生 Text;每个元素独立 try/catch,单个节点失败不阻塞整体导出。
最后残留元素检测打印警告,方便开发调试。
新方案优点
- 不依赖外部字体与网络资源
- 兼容浏览器、设计软件、后端转图各类环境
- 排版完全复刻编辑器真实布局
- 粗体/斜体/下划线样式完整还原
- 适配思维导图、流程图、白板等强排版场景
新方案缺点
- SVG 文件体积明显增大
- 文字转为路径后不可复制、不可编辑
- 导出计算耗时略有增加
- 仅支持项目提前内置的字体
- 缺字场景仍需保留原生文本兜底
两种方案最终对比
| 对比项 | @font-face方案 | 文字转 Path 方案 |
|---|---|---|
| 显示稳定性 | 一般 | 极高 |
| 跨软件兼容性 | 差 | 优秀 |
| SVG 文件体积 | 小 | 偏大 |
| 文字可编辑 | 支持 | 不支持 |
| 导出速度 | 快 | 稍慢 |
| 排版还原度 | 易受字体影响 | 100% 还原 |
总结
SVG 导出文字变形的核心,不是导出功能没做好,而是把字体渲染责任丢给了打开环境。
旧方案依赖环境兜底,新方案在导出阶段就把文字外观彻底固化。
编辑器保留可编辑文本,导出产物固化矢量路径,各司其职。
对于思维导图这类文字和图形深度绑定的项目,文字转 Path 是解决跨环境丢字、变形最稳妥的工程方案。