html要素を<canvas>に描画する

はじめに

タイトル通りです。

  1. 描画したいhtml要素をCSSごとSVGにする
  2. SVGをbase64かBlobにしてcanvasに描画する

といった手順になります。

canvasがSVGの描画に対応しているブラウザで動作します。

SVGImageElementの描画はSafariが対応していないっぽいのでHTMLImageElementとして描画します。

CanvasRenderingContext2D API: drawImage: SVGImageElement as source image | Can I use…

canvasに描画できるとなにが嬉しいの?

クライアントサイドだけでhtml要素をラスター画像として出力できます。

プレビューはhtml要素で表示して、保存するときだけcanvasに描画して画像として出力するなどが可能になります。

canvasはブラウザごとの差異が大きく(テキストの描画など)、コードが煩雑になることが多いです。

なのでここの部分をすべてhtml要素で完結出来るのはかなり嬉しいと思います。

動作サンプル

実際の動作を確認したいと思うので先にサンプルを置いておきます。

テストページ

ソースコードと解説

canvasにSVGを描画した場合、SVGに含まれる外部リソースはロードされません。

なので画像やフォントなどはbase64にして直接埋め込む必要があります。

SVGにする部分

  1. 対象となるHTMLElementをcloneNodeでコピーして画像を全てbase64に変換します。
  2. XMLSerializer.serializeToStringを使ってシリアライズします。
  3. SVG内に<foreignObject>を使ってシリアライズしたHTML(XHTML)を埋め込みます。

画像を埋め込む場合は同じホストかaccess-control-allow-origin: *である必要があります。

access-control-allow-origin: *の画像を埋め込む際は<img>crossorigin属性を"anonymous"にする必要があります。

(jsではImage.crossOrigin)

ソースコードでは画像はロード済みと仮定しています。

そうでない場合は間にImage.onloadなどを挟んでください。


// HTMLElementとCSSをSVGに埋め込んで返す
function elementToSvgText($target, cssText) {
  const $clone = $target.cloneNode(true);

  const $targetImages = $target.querySelectorAll("img");
  const $cloneImages = $clone.querySelectorAll("img");

  for (let i=0; i < $targetImages.length; i++) {
    $cloneImages[i].src = imageToBase64($targetImages[i]);
  }

  const clientRect = $target.getBoundingClientRect();
  const width = clientRect.width;
  const height = clientRect.height;
  const xmlText = new XMLSerializer().serializeToString($clone);
  return `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
      <foreignObject width="100%" height="100%" requiredExtensions="http://www.w3.org/1999/xhtml">
        <div xmlns="http://www.w3.org/1999/xhtml">
          <style>
            ${cssText}
          </style>
          ${xmlText}
        </div>
      </foreignObject>
    </svg>
  `;
}

// 画像をbase64にして返す
function imageToBase64($img, mimeType="image/png") {
  const $canvas = document.createElement("canvas");
  $canvas.width  = $img.naturalWidth;
  $canvas.height = $img.naturalHeight;
  const ctx = $canvas.getContext("2d");
  ctx.drawImage($img, 0, 0);
  return $canvas.toDataURL(mimeType);
}

CSSに外部リソースが含まれている場合

@font-facebackground-imageurl(...)をbase64にして埋め込みます。

// CSS内のurlをbase64にして埋め込む
async function replaceUrlToBase64(cssText) {
  const regex = /url\s*\(["']*(.+?)["']*\)/g;
  const promises = [];
  cssText.replace(regex, async (match, url) => {
    const promise = fetchFileBase64(url)
      .then(base64 => `url(${base64})`);
    promises.push(promise);
  });
  const data = await Promise.all(promises);
  return cssText.replace(regex, () => data.shift());
}

// ファイルを取得してbase64で返す
async function fetchFileBase64(fileUrl) {
  const req = new Request(fileUrl);
  const res = await fetch(req);
  if (!res.ok) {
    throw new Error(`fetch error ${res.status}`);
    return;
  }
  const mimeType = res.headers.get("content-type").split(";")[0];
  const aBuff = await res.arrayBuffer();
  let binary = "";
  const bytes = new Uint8Array(aBuff);
  const length = bytes.byteLength;
  for (let i = 0; i < length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return `data:${mimeType};base64,${window.btoa(binary)}`;
}

参考

おわりに

iOS 15.1のSafariでも動作することを確認。

ただ数回描画しなおさないと画像やフォントが読み込まれない場合があり要検証。

実際に導入する場合は動作確認をしっかりしないとダメ。

OSS化検討中・・・