SVG+Canvas实现生成海报——不失真

1,535 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情

前沿

最近遇到很多需求里有分享海报功能,一开始采用 html2Canvas 实现,后来发现 IOS各种兼容问题,就改用纯 Canvas 绘制的,简单的还好,但稍微复杂一些的页面就比较费劲了。

后来想,如果后面遇到非常复杂的页面需要生成图片海报,用 Canvas 绘制岂不是很苦逼么。后来真的现有遇到的问题做了些梳理并查阅了一些资料,开始有了新的思路,ts-dom-to-image就这么诞生。

遇到的常见问题

  • 跨越图片的问题

原页面  

html2Canvas 生成出来的和原页面差距很大。 文档里说跨域图片需要加给图片加上 crossorigin="anonymous"或者调用时传入{allowTaint:true,useCORS:true}这里的useCORScrossorigin一样,源码 cache-storage.ts 文件中的loadImage方法里可以看出,感兴趣可以去看看,这里我截了个图:

image.png 但是crossorigin IOS 兼容只支持15.1以上版本。

image.png 你以为这样就解决了么,虽然针对跨域图片做了处理, 但是还是有些机型有问题,比如14.x,13.x等系统还会出现跨域图片空白问题。

解决方案 :

  • 模糊失真等问题

    • 原因: 手机端屏幕、分辨率和电脑端不同。
    • 因为手机端的屏幕是Retina屏幕。假设图标icon.png是16x16, 设备是2x的Retina屏,那么你得准备一个icon@2x.png,分辨率是32x32。iPhone 6 Plus和iPhone 6S Plus是3x的Retina屏,如果要兼容,就要更多的图。
    • 所谓“Retina”是一种显示标准,是把更多的像素点压缩至一块屏幕里,从而达到更高的分辨率并提高屏幕显示的细腻程度。
    • 解决方案:使用SVG或者调整图片大小。目前我只想到这两个方法。我后面方案里就是采用SVG来实现的。
  • CSS属性支持问题 html2Canvas 官方也有所列举,比如: box-shadow 、filter、zoom ......

ts-dom-to-image方案的实现

先重点介绍下 SVG,因为方案里它可是关键的一步。最终所有不同类型的图片输出都是经过SVG来绘制的。

SVG

SVG是一种矢量格式,除作为照片之外,适用于任何类型的图像。作为可缩放矢量图形来说非常实用。这就是设计师更频繁地使用它的原因。

SVG是一种无损格式 – 意味着它在压缩时不会丢失任何数据,可以呈现无限数量的颜色,最常用于网络上的图形、徽标可供其他高分辨率屏幕上查看。

优点

  • 矢量格式可以随意调整大小
  • 能够在代码或文本编辑器中创建简单的 SVG 渲染
  • 从 Adobe Illustrator 或 Sketch 设计和导出复杂图形
  • 可以访问 SVG 文本
  • SVG 很容易设计风格和脚本
  • 现代浏览器支持 SVG 格式,并且面向未来
  • 格式具有高度可压缩性和轻量级
  • 由于基于文本的格式,因此适合搜索
  • 支持透明度
  • 允许静止或动画图像

缺点

  • 设计 SVG 可能会变得复杂
  • 不建议在某些降级的浏览器上呈现
  • 电子邮件客户端支持有限

原理

首先,克隆目标元素已经所有的子类元素,然后对克隆元素做不同资源的处理,比如:字体、图片、样式等。 等克隆元素的内容和样式等都处理完成之后,将其转换成SVG的Base64数据URL,再生成SVG。最后通过Canvas来绘制生成的SVG,最终输出你需要的不同格式的图。可以看看实现的流程图:

image.png

实现

先看看需要实现哪些功能,如:SVG、JPG、PNG、Blob、PixelData 等类型图的输出。这里实现一个 DomToImage类,类里实现这些方法即可。

export default class DomToImage {
  public options
  /**
   * constructor
   * @param props 渲染参数
   */
  constructor(options: RenderOptions) {
    const defaultValue = {
      quality: 1,  // 透明度
      cacheBust: false,
      useCredentials: false,
      httpTimeout: 30000,
      scale: window.devicePixelRatio, // 图片放大倍数
    }
    this.options = { ...defaultValue, ...options }
  }

  toSvg() {
     // 克隆元素
     // 处理克隆元素字体
     // 处理图片和样式内以及背景图
     // 生成SVG
  }
  toPng() {
   // 具体实现
  }

  toJpg() {
    // 具体实现
  }

  toCanvas() {
   // 具体实现
  }

  toBlob() {
     // 具体实现
  }

  toPixelData() {
     // 具体实现
  }
}

这里主要的是 toSVG里的实现,从流程图上可以看出,针对克隆元素有process-fontprocess-styleprocess-image三大处理。这里就只列举下关键的实现,感兴趣的同学可以去看看源码。

如何解决图片跨域

这里我采用XMLHttpRequest+ Blob文件流的方式来处理图片,包括图片元素以及样式内的图片都会经过这处理。看看具体实现:

export const xhr = (props: {
 url: string
 httpTimeout?: number
 cacheBust?: boolean // 是否绕过缓存
 useCredentials?: boolean // 是否跨域
 successHandle?: Function | undefined
 failHandle?: Function | undefined
}) => {
 let { url } = props
 const {
   httpTimeout = 30000,
   cacheBust = false,
   useCredentials = false,
   successHandle,
   failHandle,
 } = props
 if (cacheBust) {
   // Cache bypass so we dont have CORS issues with cached images
   // Source: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
   url += (/\?/.test(url) ? '&' : '?') + new Date().getTime()
 }
 return new Promise(function (resolve, reject) {
   const request = new XMLHttpRequest()
   request.onreadystatechange = handle
   request.ontimeout = () => reject(`timeout of ${httpTimeout}ms occured while fetching resource: ${url}`)

   request.responseType = 'blob'
   request.timeout = httpTimeout
   if (useCredentials) request.withCredentials = true
   request.open('GET', url, true)
   request.send()
   request.onerror = reject;
   function handle() {
     if (request.readyState !== 4) return
      if (request.status === 200) {
       if (successHandle instanceof Function) {
         successHandle(request, resolve)
       } else {
         resolve(request)
       }
     } else {
       if (failHandle instanceof Function) {
         failHandle(request, reject)
       } else {
         reject(`cannot fetch resource: ${url}, status: ${request.status}`)
       }
     }
   }
 })
}

通过这个方法获取到图片的blob数据流,再通过fileReader来读取并转换为 Base64 的 Image。

export const readUrlFileToBase64 = (props: {
  url: string
  httpTimeout?: number
  cacheBust?: boolean
  useCredentials?: boolean
  imagePlaceholder?: string // base64
}): Promise<unknown> =>
  xhr({
    ...props,
    successHandle: (
      request: { response: Blob },
      resolve: (arg0: string | ArrayBuffer | null) => void,
    ) => {
      const reader = new FileReader()
      reader.onloadend = function () {
        const content = reader.result
        resolve(content)
      }
      reader.onerror = (err) => console.error('img url reader fail', err)
      reader.readAsDataURL(request.response)
    },
  })

图片处理的功能实现,接下来就是将图片元素和样式背景图片的图片都通过这个转换成 Base64 的形式,最终也内联形式呈现这克隆元素上。

图片最终在 SVG上呈现的结果是这样的: image.png

处理字体

这里说的主要是自定义的字体的处理,其原理是把当前页面的document.styleSheets中筛选自定义字体进行处理处理。比如这样的字体:

 @font-face {
      font-family: LiuJianMaoCao-Regular;
      src: url('LiuJianMaoCao-Regular.ttf'); 
    }
 
@font-face {
      font-family: "Al-Black";
      font-weight: 1000;
      src: url("//at.alicdn.com/wf/webfont/QBa4l4xvmwzg/-xLe239h9x-gXbqAqlxsa.woff2") format("woff2"),
        url("//at.alicdn.com/wf/webfont/QBa4l4xvmwzg/pYI44mTrOmZ5yzTP60GEq.woff") format("woff");
      font-display: swap;
    }

这些自定义字体都别转换成 Base64 的形式存在,比如这样:

image.png 看看DEMO测试 效果: 4.gif

Style 处理

把所有元素的样式转化成行内样式一一对应到克隆元素上,样式处理这块包括针对background图片的处理,上面跨域图片这块已经讲过,另外针对伪类做了处理,目前只处理了beforeafter

有个要注意地方,在 safari 上针对 background-size:100% auto; 写法,正常情况省略auto并没有问题,但是当有-webkit-background-size时,以它优先垂直方向就会被拉升,因为在 safari 上通过 getComputedStyle获取到的样式会有 -webkit-background-size并没带 auto,所以这里需要特殊处理。具体实现看看下面源码:

/**
*  设置克隆元素样式
* @param  {CSSStyleDeclaration} sourceNodeCssStyle
* @param  {CSSStyleDeclaration} cloneNodeCssStyle
*/
export const setCloneNodeStyleProperty = (
 sourceNodeCssStyle: CSSStyleDeclaration,
 cloneNodeCssStyle: CSSStyleDeclaration,
) => {
 if (sourceNodeCssStyle.cssText) {
   cloneNodeCssStyle.cssText = sourceNodeCssStyle.cssText
   // TODO safari 解析Style兼容问题,100% auto 形式会自动省略 auto,这样会导致生成的背景图图高度会被默认为 100%,结果就是被拉伸了
   if (
     sourceNodeCssStyle.getPropertyValue('-webkit-background-size') ===
       '100%' &&
     util.checkBrowse().isSafari
   ) {
     cloneNodeCssStyle.removeProperty('-webkit-background-size')
   }
 } else {
   for (const key of sourceNodeCssStyle) {
     if (sourceNodeCssStyle.getPropertyValue(key)) {
       if (key !== '-webkit-background-size')
         cloneNodeCssStyle.setProperty(
           key,
           sourceNodeCssStyle.getPropertyValue(key),
           sourceNodeCssStyle.getPropertyPriority(key),
         )
     }
   }
 }
}

来看看原图和生成被拉升的对比结果:

被拉升了肯定不是我们想要的结果。另外针对 html2Canvas不支持的,常用的样式做了测试 下面示例中的都包含这些属性:

1.gif

最后就回到一开始的实现上方法,主要就是生成 Svg 过程实现:

 toSvg() {
    return Promise.resolve()
      .then((): any =>
        cloneNode(this.options.targetNode, this.options.filter, true),
      )
      .then(processFonts)
      .then(checkElementImgToInline) // 图片和背景图转内联形式
      .then(this.applyOptions.bind(this))
      .then((clone) => {
        clone.setAttribute('style', '')
        return createSvgEncodeUrl(
          clone,
          this.options.width || util.width(this.options.targetNode),
          this.options.height || util.height(this.options.targetNode),
        )
      })
  }

其他的类型都是基于 SVG的基础上再通过 Canvas来绘制的,这一步就相对简单了,我这里就不细说了。 另外关于使用以及参数配置可以看下 ts-dom-to-image 说明文档。

最后

欢迎有兴趣的朋友去体验使用,如有问题欢迎提 issues,也可以在评论去说明。如觉得还不错的,也不妨点个👍或 start 就万分感谢了!

资源文献

crossorigin

html2Canvas

domvas

浏览器端网页截图方案详解