让 SVG 不再“丢字变形”:一次思维导图导出文字转 Path 的实战优化

让 SVG 不再"丢字变形":一次思维导图导出文字转 Path 的实战优化

在做在线思维导图导出 SVG 时,我遇到了一个很隐蔽但很影响体验的问题:编辑器里看起来好好的文字,导出后换个环境打开就变样了。

前言

最近在做一个基于 Vue 3 + Vite 的在线思维导图项目:gimi-mind-map

这个项目主要做的是在线编辑思维导图,支持节点编辑、主题样式、概要、外框、关系线、图片、XMind 导入导出,以及 PNGJPEGPDFSVG 等多种格式导出。

项目信息:

  • 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-family
  • font-size
  • font-weight
  • font-style
  • line-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 的整体结构

这个文件可以拆成几层来看:

  1. 字体映射和字体加载
  2. 字体名解析
  3. 字形缺失检查
  4. HTML 文本位置测量
  5. 坐标转换
  6. path 生成
  7. 粗体、斜体、下划线等样式补偿
  8. SVG 原生 <text> 处理
  9. 总入口统一调度

整条流水线可以概括成:

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]

这一步非常妙。

它不是根据字符串长度估算位置,而是直接拿浏览器真实布局后的字符矩形。

每个字符会得到:

  • left
  • top
  • right
  • bottom
  • width
  • height

这样自动换行、手动换行、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)

这里用到了字体的:

  • ascender
  • unitsPerEm

简单理解:

  • 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 这个方案也许能帮你少踩很多字体和排版的坑。

相关推荐
sp421 小时前
NativeScript 5.1:直接集成 Objective-C 代码
前端·javascript
UXbot1 小时前
AI一次生成iOS和Android双端原型功能详解
android·前端·ios·kotlin·交互·swift
我是卡卡啊1 小时前
View 绘制深度分析:HWUI · RenderThread · SurfaceFlinger
前端
产品经理爱开发1 小时前
国内免费快速HTML托管平台推荐:优先艾可秀,零门槛秒上线
前端·html
蜡台1 小时前
idea 配置 vue 运行命令时, scripts 一栏始终为空
前端·vue.js·intellij-idea
杨前端布洛芬1 小时前
仿某钉打卡 UniApp 版
前端
超绝大帅哥1 小时前
RAG检索策略及划分策略
前端
小盼江1 小时前
Uniapp小程序鲜花商城推荐系统 买家卖家双端(web+uniapp)
前端·小程序·uni-app
lihaozecq1 小时前
Agent 工具系统搭建:4 个内置工具让 Agent 学会写代码
前端