01月03, 2020

解决 html2canvas 处理 svg 时样式丢失的问题

在项目开发中,经常会遇到截图的需求(如:把结果生成图片用于分享,生成模块的封面图),此时用 html2canvas 来完成这个操作是最适合不过了。

html2canvas 是一款非常优秀的截图 JS 工具,直接在浏览器端运行,不依赖服务端。当前最新版本是 1.0.0-rc.5,使用起来很简单,DEMO

html2canvas(document.body).then(function(canvas) {
  document.body.appendChild(canvas);
  // 把 canvas 转成图片
  // const image = new Image();
  // image.src = canvas.toDataURL("image/png");
  // document.body.appendChild(image);
});

html2canvas 会自动识别普通 html 节点内容中的样式,不管样式是在外层用 link/style 定义的,还是直接写在元素上的。但如果截图的内容中含有 svg,且样式是在外层定义的话,会发现样式丢失,DEMO

原始内容给 svg text 节点设置了字体大小和颜色,截图中这二个样式都丢失了。

为了弄清楚 html2canvas 在处理 svg 时样式丢失的问题,先需要弄清楚 html2canvas 的处理机制。

html2canvas 实现机制

html2canvas 的实现机制是通过遍历分析指定节点以及子节点的样式,然后通过 canvas API 在 canvas 中绘制出来,具体如下:

  • 为了避免对当前页面的影响,将当前页面的所有节点拷贝到一个 iframe 中
    • ownerDocument 开始递归,拷贝节点
    • 如果节点是 script,则忽略
    • 如果节点命中了 ignoreElements 则忽略
    • 记录指定节点对应的拷贝节点
    • 动态创建 iframe,将拷贝的节点写入到 iframe
    • 调用 onclone 方法
  • 通过内部的 parseTree 方法遍历解析拷贝节点及子节点,生成对应的 container,如:ImageElementContainerSVGElementContainerCanvasElementContainer
  • 最后通过 CanvasRendererForeignObjectRenderer 类来渲染这些节点

从上面的流程中可以知道,svg 节点是通过 SVGElementContainer 来处理的。通过代码发现 svg 的处理非常简单粗暴:

export class SVGElementContainer extends ElementContainer {
    svg: string;
    intrinsicWidth: number;
    intrinsicHeight: number;

    constructor(img: SVGSVGElement) {
        super(img);
        const s = new XMLSerializer();
        this.svg = `data:image/svg+xml,${encodeURIComponent(s.serializeToString(img))}`;
        this.intrinsicWidth = img.width.baseVal.value;
        this.intrinsicHeight = img.height.baseVal.value;

        CacheStorage.getInstance().addImage(this.svg);
    }
}

也就是说,对于 svg,是直接将 svg 序列化,然后转成图片来处理的。这样一来,定义在外部的样式序列化时无法携带,自然也就丢失了。

修复 svg 的样式问题

知道了 svg 是序列化后转成图片来处理的,只要保证在序列化时携带上样式,生成的图片后就没有问题了。

样式直接定义在节点上

最简单的办法就是,将原本定义在外层的样式写在 svg 节点上,DEMO

<svg width="200" height="100">
   <text x="10" y="30" style="font-size: 24px;fill: red">svg text 内容</text>
</svg>

这样处理虽然能解决问题,但比较麻烦,尤其是 svg 是放在数据库/文件等地方存储时。

动态将外层定义的样式重新赋值到节点上

除了手工把样式写在 svg 节点上外,也可以在生成截图前解析节点的样式,然后把样式重新设置到节点上,这样 svg 序列化时携带这些信息,生成的图片就会有这些样式了。

前面流程中提到,页面所有节点拷贝完成后会调用 onclone 方法,那么就可以在这个方法里动态设置,DEMO

html2canvas(container, {
    onclone(html) {
      const textNodes = $(html).find('svg.svg text');
      // 根据需要修改要重新赋值的属性
      const styles = ['font-size', 'fill']; 
      textNodes.each(function() {
        const textStyle = window.getComputedStyle(this);
        styles.forEach(item => {
          const value = textStyle.getPropertyValue(item);
          $(this).css(item, value);
        })
      })
    }
})

svg 中含有图片的处理

如果 svg 中含有图片且图片地址是远程的,直接序列化后截图中并不能正常显示图片。借助 onclone 可以返回 promise,把图片转成 base64 后回写到原图片中,DEMO

onclone(html) {
  const imageNodes = $(html).find('svg.svg image');
  const promises = [];
  imageNodes.each(function() {
    const element = $(this);
    const href = element.attr('href');
    if(href.startsWith('base64')) return;
    const promise = new Promise((resolve, reject) => {
      const img = new Image();
      img.crossOrigin = 'anonymous';
      img.onload = function() {
        const width = parseFloat(element.css('width'));
        const height = parseFloat(element.css('height'));
        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0, width, height);
        const base64 = canvas.toDataURL("image/jpeg");
        element.attr('href', base64);
        resolve();
      }
      img.onerror = reject;
      img.src = href;
    });
    promises.push(promise);
  })
  return Promise.all(promises)
}

svg 含有自定义字体的处理

有时候页面中通过 @font-face 引用了自定义字体,如果 svg 中用到了这个字体,那么必须要把字体以内联的方式放置到 svg 中,才能让生成的图片中字体一致,DEMO

onclone(html) {
  const promises = [];
  const svg = $(html).find('svg.svg');
  svg.find('text').each(function() {
    const family = window.getComputedStyle(this).getPropertyValue('font-family');
    if(family === 'Merriweather') {
      $(this).css('font-family', family)
      const promise = fetch(`https://lib.baomitu.com/fonts/merriweather/merriweather-regular.woff2`, {
      }).then(res => res.blob()).then(data => {
        return new Promise((resolve, reject) => {
          const fr = new FileReader();
          fr.onload = function(e) {
            resolve(e.target.result)
          }
          fr.readAsDataURL(data)
        })
      }).then(data => {
        const node = `<defs>
        <style>
          @font-face {
            font-family: 'Merriweather';
            font-style: normal;
            font-weight: regular;
            src: local('Merriweather'), local('Merriweather-Normal'), url('${data}') format('woff2');
          }
        </style>
        </defs>`;
        $(node).insertBefore(svg.children(":first"));
      })
      promises.push(promise);
    }
  })
  return Promise.all(promises).then(() => {
    console.log(svg[0])
  });
}

通过获取元素的 font-family,然后动态加载对应的字体,转成 base64,生成 defs 标签插入到 svg 中。

svg 的其他处理方案

除了 html2canvas 里把 svg 转成图片的方案,也可以使用其他的方案,如:canvg 是一款把 svg 转成 canvas 并支持事件、动效等功能的 JS 库。这样就可以利用 canvg 库,把 svg 转成 canvas 后,替换原有的 svg 节点。

不过利用 canvg 也需要自己处理 svg 里使用的外部样式,同时引入 canvg 也需要加载一个额外的 JS 文件,是否需要可以根据项目评估。

性能问题

有时候会发现使用 html2canvas 截图时比较慢,原因在于处理时会把当前页面拷贝一份到 iframe 中操作。如果页面很复杂,节点数量很多,拷贝节点所花费的时间就会比较长。

可以通过 ignoreElements 方法来处理这个问题,在拷贝的时候把不需要的节点忽略掉,提升拷贝的速度,DEMO

ignoreElements: el => {
  const tagName = el.tagName.toLowerCase();
  const list = ['head', 'body', 'style', 'title', 'meta']
  if(list.includes(tagName)) return false;
  // id="extra" 下所有节点忽略
  if(el.id === 'extra') return true;
  return false;
}

除了 ignoreElements 方法中自己指定忽略的元素外,也可以在元素上添加 data-html2canvas-ignore 属性忽略。

<div data-html2canvas-ignore class="test">
 忽略的节点内容
</div>

部分事件的额外执行

截图时,由于是把当前页面拷贝到 iframe 中操作的,虽然会自动忽略 script 等节点,但如果元素上有属性事件时,默认还是会拷贝过去。这样会导致事件的多次执行,DEMO

<img src="https://p0.ssl.qhimg.com/t01d22afab8ee7b5a06.jpg" onload="xxx('图片加载完成')" width="100">

如果方法 xxx 是系统内置的函数,则会再执行一次。如果是外部的函数,则会报错 xxx 不存在。

处理办法是在 ignoreElements 中判断,然后移除这个属性,不过这个是操作原本的元素,不是拷贝后的元素,这个影响是否可以接受需要根据项目评估,DEMO

ignoreElements: function(el) {
  if(el.tagName === 'IMG') {
    el.removeAttribute('onload')
  }
}

本文链接:http://www.welefen.com/post/html2canvas-with-svg.html

-- EOF --

Comments