从 SVG 到 PNG 的奇幻转换:如何用纯 JavaScript 完美导出

2,553 阅读3分钟

一、前言

LogicFlow 提供了便捷的导出画布功能,允许用户将画布上的所有内容以图片形式下载保存,这在前端叫做截图技术,网上也有许多教程讲解如何进行 dom 截图,一般使用主流库 html2canvas 快速实现,但 LogicFlow 的画布是基于 SVG 的,一方面 html2canvas 明确不支持 svg 截图,另一方面, svg 截图也并不复杂,原生 JS 足以实现。因此,基于 LogicFlow 实践案例,作者就带大家一步一步去实现一个简易 demo:用原生 JS 将 SVG 导出为常见的 PNG 图片,并解决其中可能遇到的一些细节问题,希望大家读完能够有所收获。

二、demo效果

左图是使用 <foreignObject> 内嵌的 HTML,右图是下载后的图片:

image.png download.png

三、实现过程

假设你要实现这个功能,先思考一下该如何进行呢。如果一开始没有头绪,别担心,下面我会将问题拆分,并引导你一步一步去实现它:

1. 如何下载一张图片

这个问题不难,属于经验问题:任何图片我们只要有这个它的链接 url ,我们可以借助 <a> 标签的 download 属性去告诉浏览器:点击这个 <a> 标签直接下载该图片。

 <button>下载图片</button>

 <script>
   document.querySelector("button").addEventListener("click", () => {
     const a = document.createElement("a");
     a.href = 'https://example.com/image.png'; // 替换为你的图片 URL
     a.download = 'image.png'; // 默认文件名
     a.click();
   });
 </script>

所以,下载图片等资源,我们只要有资源链接,就可以通过 <a>download 去下载它。

2. 如何将SVG转换为png

LogicFlow 画布本质是一个 <svg> 标签元素,所有的元素都在这个 svg 元素里,我们目标是将 svg 转换为 png 图片,canvas 是最佳选择,因为:

  • canvas 提供了生成 pngjpeggif 类型图片的能力。
  • 可直接生成 base64 格式的图片 url

转化流程:

  1. 先创建 canvas 元素。
  2. svg 元素序列化为 base64 字符串。
  3. 创建 img 元素,将 base64 字符串挂载到 img 上。
  4. img 元素绘制到 canvas 中,并返回 canvas 元素。
  5. 借助 canavs.toDataURL 生成 png 类型的 base64 url
 const svg = document.querySelect('svg')
 const canvas = document.createElement('canvas')
 const ctx = canvas.getContext('2d')

 const { width, height } = svg.getBoundingClientRect()
 canvas.width = width
 canvas.height = height

 // svg 序列化为string:其实就是html代码文本 
 const data = new XMLSerializer().serializeToString(svg)
 const img = new Image()
 // svg string 转化为 base64
 img.src = `data:image/svg+xml;charset=utf-8,${data}`

 img.onload = () => {
   ctx.drawImage(img, 0, 0)
   const url = canvas.toDataURL(`image/png`)
   // ...download
 }

一些思考🤔️

  1. 为什么要将 svg 序列化为 base64 字符串,再通过 img 绘制到 canvas 中呢?

因为 canvas 不支持直接绘制 svg 元素,所以需要先将 svg 元素序列化为 base64 字符串,挂载到 img 元素上,再把 img 绘制到 canvas 中。

  2. 已经将 svg 元素序列化为 base64 字符串,为什么还需要通过 canvas 生成 base64 字符串?

因为 这个 base64 仅仅存储着 svg 类型图像数据,而 canvas 可以生成各种类型图像数据的 base64 ,比如 pngjpeg 等,所以我们需要通过 canvas 来生成多种图像类型的 base64 字符串。

3. 如何调整图片清晰度

canvas 绘制的图形是位图,位图图像由像素构成,同一宽高的 canvas 物理像素大小是固定的。在高分辨率显示屏上,每个点需要更多物理像素,因而 canvas 会显得模糊,所以我们需要根据不同分辨率显示器来调整 canvas 像素大小,调整 canvas 宽高就可以调整物理像素大小。

通过 window.devicePixelRatio 获取屏幕设备像素比,根据设备像素比自动调整 canvas 的导出宽高以调整物理像素大小来适配不同分辨率显示屏的图片清晰度。

 const dpr = window.devicePixelRatio || 1;

 canvas.width = width * dpr; // 物理像素宽度
 canvas.height = height * dpr; // 物理像素高度

 // 调整绘图上下文的缩放:
 const context = canvas.getContext('2d');
 context.scale(dpr, dpr);
  • 清晰度提升: 当 dpr 增大时,你实际上是在提升 canvas 的物理分辨率,这样绘制的 canvas 的图像会更加清晰,因为每个点包含更多的物理像素信息。

  • 平衡清晰度和文件大小: 较大的 dpr 会导致生成的图片文件更大,这可能会影响性能和加载时间。通常,设置 dpr 为 2 或 3 可以获得较好的清晰度和合理的文件大小平衡。

4. 解决网络图片丢失问题

现象svg 元素中含有网络图片,不论是 <image> 原生的 svg 图像标签,还是 foreignObject 中的 <img> 图像标签,导出图片后这些网络图片都会丢失。

原因:在我们将 svg 序列化为 base64 时,XMLSerializer 只将原始的 svg 字符串进行编码,不会将外部资源嵌入到 base64 数据中。换句话说,外部图像不会被自动下载并嵌入到这个 base64 编码的 svg 数据中,网络图片也就是在这个时候丢失的。

解决:将网络图片 url 转化为本地 base64 地址,这样图片资源就不丢失了。这里借助 fetch 将网络 url 转化为 blob 对象,再将 blob 对象转化为 base64 地址。

 // 将网路 url 转化为本地 url
 async function fetchToLocalUrl(url) {
   try {
     // 使用 fetch 请求图片
     const response = await fetch(url);
     if (!response.ok) {
       throw new Error("Network response was not ok");
     }
     // 将响应转换为 Blob
     const blob = await response.blob();

     const reader = new FileReader();
     reader.readAsDataURL(blob);

     await new Promise((resolve) => {
       reader.onloadend = resolve;
     });

     return reader.result;
   } catch (error) {
     console.error("Error:", error);
     return null; // 返回 null 或原始 URL 以防出错
   }
 }

5. 解决 css 样式丢失

原因:我们在序列化 svg 时,css 样式不会被携带上,我们需要手动注入,可以直接将style 注入到 svg 中,这样 css 样式就会被携带上了。

 // 注入 css
 const style = document.querySelector("style").cloneNode(true);
 svg.insertBefore(style, svg.firstChild);

四、完整代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>导出 svg</title>
  </head>
  <style>
    .card {
      background-color: white;
      margin: 2em;
      display: flex;
      box-shadow: 0 0 1em rgba(0, 0, 0, 0.2);
      border-radius: 0.5em;
      width: 400px;
    }

    .cover {
      width: 180px;
    }

    .cover img {
      display: block;
      height: 100%;
      width: 100%;
      border-radius: 0.5em 0 0 0.5em;
    }

    .text {
      flex: 1;
      text-align: center;
      padding-top: 2em;
    }

    .text h1 {
      font-size: 1em;
    }
  </style>
  <body>
    <svg width="470" height="320" viewBox="0 0 470 320">
      <foreignObject width="100%" height="100%">
        <div xmlns="http://www.w3.org/1999/xhtml">
          <div class="card">
            <div class="cover">
              <img src="./bgc.png" />
            </div>
            <div class="text">
              <h1>生活哲学</h1>
              <p>珍惜当下每一刻,</p>
              <p>用心去感受生活。</p>
              <p>用行动去追求梦,</p>
              <p>幸福在于微小瞬间。</p>
            </div>
          </div>
        </div>
      </foreignObject>
    </svg>
    <button>生成图片</button>
    <canvas></canvas>
  </body>
  <script>
    // 将网路 url 转化为本地 url
    async function fetchToLocalUrl(url) {
      try {
        // 使用 fetch 请求图片
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error("Network response was not ok");
        }
        // 将响应转换为 Blob
        const blob = await response.blob();

        const reader = new FileReader();
        reader.readAsDataURL(blob);

        await new Promise((resolve) => {
          reader.onloadend = resolve;
        });

        return reader.result;
      } catch (error) {
        console.error("Error:", error);
        return null; // 返回 null 或原始 URL 以防出错
      }
    }

    // 点击下载
    document.querySelector("button").addEventListener("click", async () => {
      const svg = document.querySelector("svg");
      const canvas = document.querySelector("canvas");
      const context = canvas.getContext("2d");
      const dpr = window.devicePixelRatio ?? 1;


      // 处理清晰度
      const { width, height } = svg.getBoundingClientRect();
      canvas.width = width * dpr;
      canvas.height = height * dpr;
      context.scale(dpr, dpr);

      // 注入 css
      const style = document.querySelector("style").cloneNode(true);
      svg.insertBefore(style, svg.firstChild);

      // 网络图片 url 转化为本地 url
      const onlineImg = svg.querySelector("img");
      const localUrl = await fetchToLocalUrl(onlineImg.src);
      onlineImg.src = localUrl;
      // 确保图片加载完成
      await new Promise((resolve) => (onlineImg.onload = resolve));

      // 将 svg 序列化为 string:其实就是 html 代码文本
      const data = new XMLSerializer().serializeToString(svg);
      const img = new Image();
      // svg string 转化为 base64
      img.src = `data:image/svg+xml;charset=utf-8,${data}`;

      // 等待 img 加载完后绘制 canvas 并执行下载
      img.onload = async () => {
        context.drawImage(img, 0, 0);
        const a = document.createElement("a");
        a.download = "image.png";
        a.href = canvas.toDataURL("image/png");
        a.click();
      };
    });
  </script>
</html>

五、总结

文章到这里就结束了,简单总结下:

  1. 可以使用 <a> 标签的 download 属性下载资源。
  2. 借助 canvassvg 转化为 png
  3. 借助 window.devicePixelRatio 优化图片清晰度。
  4. 借助 fetch 将网络图片本地化解决网络图片丢失问题。
  5. 手动注入 css 样式解决 css 样式丢失问题。

最后:

本篇文章基于 LogicFlow 中的「Snapshot」插件实现原理进行提炼。如果您希望深入了解相关细节,请参阅该插件的文档。

我们非常欢迎大家积极参与贡献,也欢迎大家联系我们与我们交流,如果能有个 Star 就更好啦~