SVG导出实战:从原理到前端工程化落地的完整方案

一红梅 工具 阅读 2,352
赞 10 收藏
二维码
手机扫码查看
反馈

SVG导出乱码、空白、样式丢失?我折腾了两天才搞定

上周在搞一个可视化配置工具,用户画完图要能导出 SVG 文件。本来以为就是 outerHTML 一拿,丢给 Blob 就完事了,结果导出来的文件打开全是乱码,或者干脆一片空白,连个影子都没有。更离谱的是,有些样式明明页面上显示正常,导出后直接没了。折腾了快两天,踩了一堆坑,最后总算搞定了。今天就记一下这个过程,省得下次再翻车。

SVG导出实战:从原理到前端工程化落地的完整方案

一开始的“天真”方案:直接 outerHTML

我最开始的想法特别简单:把 SVG 元素整个拷贝出来,转成字符串,然后用 URL.createObjectURL 生成下载链接。代码大概长这样:

const svgElement = document.querySelector('#my-svg');
const svgString = new XMLSerializer().serializeToString(svgElement);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'export.svg';
a.click();

看起来没毛病对吧?但实际导出的文件,用浏览器打开要么是白屏,要么文字变成一堆方块(比如中文全变 □□□),更诡异的是,有些内联样式压根没生效。我一度怀疑是不是浏览器兼容性问题,换 Chrome、Safari、Firefox 都试了,结果一样——说明不是浏览器的问题,是我漏了啥。

第一个坑:没声明 XML 和编码

后来我对比了别人能正常打开的 SVG 文件,发现开头都有一行:

<?xml version="1.0" encoding="UTF-8"?>

而我导出的字符串根本没有这行!SVG 虽然是 XML 的一种,但如果你不显式声明编码,很多编辑器或浏览器会默认用其他编码(比如 ISO-8859-1)去解析,中文自然就乱码了。赶紧加上:

const svgString = new XMLSerializer().serializeToString(svgElement);
const fullSvgString = &lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;n${svgString};

这下中文不乱了,但样式还是有问题。有些用 CSS 类写的样式完全没应用上。

第二个坑:CSS 样式没内联

我页面上的 SVG 是用 <style> 标签配合 class 写样式的,比如:

<svg id="my-svg">
  <style>
    .label { fill: #333; font-family: "PingFang SC", sans-serif; }
  </style>
  <text class="label" x="10" y="20">你好</text>
</svg>

但导出后,虽然 <style> 标签还在,但很多软件(比如 Illustrator、甚至某些浏览器)根本不认 SVG 里的 <style>,只认内联的 style 属性或 presentation attributes(比如 fillfont-family 直接写在元素上)。

这里我踩了好几次坑。一开始想用 getComputedStyle 去读每个元素的最终样式,然后手动转成属性。但 SVG 的样式属性和 CSS 不完全一一对应,比如 font-family 在 SVG 里可以直接用,但 color 就不行,得用 fill。而且 getComputedStyle 返回的是计算后的值,像 font-family 可能变成 "PingFang SC", "Microsoft YaHei", sans-serif,但 SVG 里如果字体名带空格又不加引号,可能解析失败。

后来试了下发现,其实最稳妥的方式是:**在导出前,把所有需要的样式都写成内联属性**。但这对现有项目改动太大。有没有自动化方案?

终极方案:递归遍历 + 内联关键样式

我最后采用了一个折中办法:导出前,深拷贝整个 SVG 元素,然后遍历所有子节点,把常用的 presentation attributes 从 computed style 里提取出来,写成属性。只处理几个关键属性,比如 fillstrokefont-familyfont-sizeopacity 等。

注意:不能直接改原始 DOM,所以先 clone 一份:

function inlineSvgStyles(svgElement) {
  const clonedSvg = svgElement.cloneNode(true);
  
  // 获取所有需要处理的元素(排除 <style>, <defs> 等)
  const elements = clonedSvg.querySelectorAll('*');
  elements.forEach(el => {
    if (el.tagName === 'style' || el.tagName === 'defs') return;
    
    const computedStyle = window.getComputedStyle(el);
    
    // fill
    const fill = computedStyle.fill;
    if (fill && fill !== 'none' && !el.hasAttribute('fill')) {
      el.setAttribute('fill', fill);
    }
    
    // stroke
    const stroke = computedStyle.stroke;
    if (stroke && stroke !== 'none' && !el.hasAttribute('stroke')) {
      el.setAttribute('stroke', stroke);
    }
    
    // font-family
    const fontFamily = computedStyle.fontFamily;
    if (fontFamily && !el.hasAttribute('font-family')) {
      // 清理掉多余的引号和空格,但保留字体名中的空格
      let cleanFont = fontFamily.replace(/["']/g, '');
      el.setAttribute('font-family', cleanFont);
    }
    
    // font-size
    const fontSize = computedStyle.fontSize;
    if (fontSize && !el.hasAttribute('font-size')) {
      el.setAttribute('font-size', fontSize);
    }
    
    // opacity
    const opacity = computedStyle.opacity;
    if (opacity !== '1' && !el.hasAttribute('opacity')) {
      el.setAttribute('opacity', opacity);
    }
  });
  
  return clonedSvg;
}

然后导出流程改成:

const svgElement = document.querySelector('#my-svg');
const styledSvg = inlineSvgStyles(svgElement);
const svgString = new XMLSerializer().serializeToString(styledSvg);
const fullSvgString = &lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;n${svgString};
const blob = new Blob([fullSvgString], { type: 'image/svg+xml;charset=utf-8' });
// ...后续下载逻辑

注意 Blob 的 type 里我也加了 charset=utf-8,双重保险。

还有个小问题:字体 fallback 不生效

虽然加了 font-family,但如果用户电脑没有 “PingFang SC”,理论上应该 fallback 到 sans-serif。但在很多 SVG 查看器里,fallback 链会被忽略,直接显示系统默认字体(可能很丑)。这个问题我暂时没解决,因为 SVG 规范本身对字体支持就比较弱。我的 workaround 是:在导出前,尽量用通用字体(比如 Arial, sans-serif),或者干脆把文字转成 path(但这就超出本文范围了)。

目前这个方案在 Chrome、Safari、Firefox 导出后都能正常显示,用 Adobe Illustrator 打开也基本 OK(除了个别字体问题)。算是达到了可用状态。

核心代码就这几行(整合版)

把上面的逻辑整合成一个函数,方便复用:

function downloadSvgAsFile(svgElement, filename = 'export.svg') {
  // 深拷贝并内联样式
  const clonedSvg = svgElement.cloneNode(true);
  const elements = clonedSvg.querySelectorAll('*');
  elements.forEach(el => {
    if (['style', 'defs', 'script'].includes(el.tagName.toLowerCase())) return;
    
    const computedStyle = window.getComputedStyle(el);
    
    const setAttrIfNotExists = (attr, value) => {
      if (value && value !== 'none' && !el.hasAttribute(attr)) {
        el.setAttribute(attr, value);
      }
    };
    
    setAttrIfNotExists('fill', computedStyle.fill);
    setAttrIfNotExists('stroke', computedStyle.stroke);
    setAttrIfNotExists('font-family', computedStyle.fontFamily.replace(/["']/g, ''));
    setAttrIfNotExists('font-size', computedStyle.fontSize);
    if (computedStyle.opacity !== '1') {
      setAttrIfNotExists('opacity', computedStyle.opacity);
    }
  });
  
  // 序列化
  const svgString = new XMLSerializer().serializeToString(clonedSvg);
  const fullSvgString = &lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;n${svgString};
  
  // 创建下载
  const blob = new Blob([fullSvgString], { type: 'image/svg+xml;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

调用起来就一行:

downloadSvgAsFile(document.querySelector('#my-svg'), 'chart.svg');

踩坑提醒:这三点一定注意

  • XML 声明和 UTF-8 编码必须加,否则中文乱码跑不掉
  • 不要依赖 SVG 内部的 <style> 标签,大多数非浏览器环境不支持
  • cloneNode(true) 而不是直接操作原 DOM,避免副作用

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有现成的库能自动处理这些?或者对字体 fallback 有更好的处理方式?我目前这个方案虽然 work,但肯定不是最优雅的。后续如果遇到更复杂的场景(比如滤镜、渐变),可能还得再优化。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论