📸

html2canvasを使えば一瞬だけど、DOMのスクリーンショットの仕組みをあえて自前実装して理解する

に公開

はじめに

ブラウザでDOMのスクリーンショットを撮りたいとき、html2canvasを使えば数行で実現できます。

const canvas = await html2canvas(document.body);
const dataUrl = canvas.toDataURL('image/png');

これで終わりです。とても便利ですね。

ただ、html2canvasが内部で何をやっているのか気になったので、簡易的な実装を通じて仕組みを理解してみることにしました。完全な再現は目指さず、「なるほど、こういうことをやっているのか」と納得できる程度の実装です。参考程度に読んでいただければと思います。

今回作成したデモはこちらです。

なぜライブラリが必要なのか

そもそも、ブラウザには「表示中のページを画像化するAPI」が標準として存在しないようです。

Firefoxには canvas.drawWindow() という非標準APIがありましたが、Webコンテンツからは使用できず、2021年に非推奨となり現在は削除されています。chrome.tabs.captureVisibleTab() はChrome拡張機能専用です。通常のWebページからはどちらも使えません。

そのため、html2canvasは「DOMを解析して、Canvasに描き直す」というアプローチを取っています。

ちなみに、同様のライブラリとして dom-to-image や html-to-image がありますが、これらはSVGの <foreignObject> にDOMを埋め込み、それを画像化するアプローチです。ブラウザのレンダリング機能を借りる分、再現性が高い場合もあるようです。

一方、html2canvasはブラウザの描画機能を借りずに自前で計算して描き直しているため、CSSの再現性に限界が出てきます。この点は後述します。

基本方針

やることはシンプルかと思います。

  1. 対象要素とその子孫を再帰的に走査する
  2. 各要素の getComputedStyle() でスタイルを取得する
  3. Canvas APIを使って描画する

この流れを繰り返していきます。

最小限の実装

まずは動くものを作ってみました。対応するのは以下だけです。

  • 背景色
  • テキスト(色、フォント)
  • padding(テキスト位置の調整)

HTML

<div id="target">
  <h1>見出しです</h1>
  <p>これはサンプルテキストです</p>
  <div class="card red">赤いカード</div>
  <div class="card blue">青いカード</div>
</div>

<button id="capture">キャプチャしてダウンロード</button>

CSS

body {
  font-family: sans-serif;
  background: #f5f5f5;
  padding: 20px;
}

#target {
  background: white;
  padding: 20px;
}

h1 {
  color: #333;
  margin: 0 0 10px 0;
}

p {
  color: #666;
  margin: 0 0 20px 0;
}

.card {
  padding: 15px;
  margin-bottom: 10px;
  color: white;
}

.red { background: #e74c3c; }
.blue { background: #3498db; }

#capture {
  margin-top: 20px;
  padding: 12px 24px;
  background: #333;
  color: white;
  border: none;
  cursor: pointer;
}

JavaScript

document.getElementById('capture').addEventListener('click', () => {
  const target = document.getElementById('target');
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  
  const targetRect = target.getBoundingClientRect();
  canvas.width = targetRect.width;
  canvas.height = targetRect.height;
  
  // 対象要素の背景色で塗る
  const targetStyle = getComputedStyle(target);
  ctx.fillStyle = targetStyle.backgroundColor;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  
  // 対象要素を起点に描画開始
  renderElement(ctx, target, -targetRect.left, -targetRect.top);
  
  // ダウンロード
  const a = document.createElement('a');
  a.href = canvas.toDataURL('image/png');
  a.download = 'screenshot.png';
  a.click();
});

function renderElement(ctx, element, offsetX, offsetY) {
  const style = getComputedStyle(element);
  const rect = element.getBoundingClientRect();
  
  const x = rect.left + offsetX;
  const y = rect.top + offsetY;
  const w = rect.width;
  const h = rect.height;
  
  // 背景色を描画
  if (style.backgroundColor !== 'rgba(0, 0, 0, 0)') {
    ctx.fillStyle = style.backgroundColor;
    ctx.fillRect(x, y, w, h);
  }
  
  // 子要素を処理
  for (const child of element.childNodes) {
    if (child.nodeType === Node.TEXT_NODE) {
      // テキストノードの場合
      const text = child.textContent.trim();
      if (text) {
        ctx.fillStyle = style.color;
        // fontFamilyは複雑な値が入ることがあるため、ここでは簡略化
        ctx.font = `${style.fontWeight} ${style.fontSize} sans-serif`;
        ctx.textBaseline = 'top';
        
        const paddingLeft = parseFloat(style.paddingLeft) || 0;
        const paddingTop = parseFloat(style.paddingTop) || 0;
        ctx.fillText(text, x + paddingLeft, y + paddingTop);
      }
    } else if (child.nodeType === Node.ELEMENT_NODE) {
      // 要素ノードの場合は再帰
      renderElement(ctx, child, offsetX, offsetY);
    }
  }
}

コードの解説

getBoundingClientRect()

const rect = element.getBoundingClientRect();

要素のビューポート上での位置とサイズを取得します。返り値は { top, left, right, bottom, width, height } を含むオブジェクトです。

getComputedStyle()

const style = getComputedStyle(element);

ブラウザが計算した最終的なスタイル値を取得します。CSSで color: red と書いていても、ここでは rgb(255, 0, 0) のような計算済みの値が返ってきます。

なお、今回の実装では style.backgroundColor !== 'rgba(0, 0, 0, 0)' で透明判定をしていますが、ブラウザによっては 'transparent' が返ることもあるようです。厳密にやるなら alpha 値を解析する必要があるかもしれません。

オフセットの計算

renderElement(ctx, target, -targetRect.left, -targetRect.top);

getBoundingClientRect() はビューポート座標を返すので、対象要素の左上を原点(0,0)にするためにオフセットを引いています。

再帰的な走査

for (const child of element.childNodes) {
  if (child.nodeType === Node.TEXT_NODE) {
    // テキスト描画
  } else if (child.nodeType === Node.ELEMENT_NODE) {
    renderElement(ctx, child, offsetX, offsetY);
  }
}

childNodes にはテキストノードと要素ノードが混在しています。テキストノードは描画、要素ノードは再帰呼び出しで処理しています。

なお、今回の実装ではテキスト位置を padding から単純に計算していますが、実際のレイアウトでは text-alignline-heightvertical-align、他のインライン要素の存在などが影響します。正確なテキスト位置を取得するには Range.getClientRects() などを使って文字単位の矩形を取得する必要があり、ここが自前実装の最難関ポイントの一つかもしれません。

対応していないもの

この実装では以下に対応していません。

項目 複雑になる理由
border 角丸との組み合わせでパス計算が必要
border-radius arc()clip() の組み合わせが必要
box-shadow 複数影、spread、insetの処理が複雑
background-image CORS対応、グラデーション解析が必要
transform 行列計算と座標変換が必要
overflow: hidden クリッピング領域のスタック管理が必要
position: fixed/absolute 通常フローと別の座標計算が必要
z-index スタッキングコンテキストの再現が必要
複数行テキスト 行の折り返し計算、line-height処理が必要
inline要素 テキストと要素が混在する場合の位置計算が複雑

特に z-index の再現は厄介です。CSSには「スタッキングコンテキスト(Stacking Context)」という概念があり、z-index だけでなく opacitytransform が指定されただけでも新しいスタッキングコンテキストが生成され、重なり順のルールが変わります。これを正確に再現しようとすると、ブラウザの描画エンジンに近い処理が必要になってきます。

こうして見ると、html2canvasがいかに大変な実装をしているか想像できるかと思います。

実用的な拡張例

もう少し実用的にするなら、以下のような拡張が考えられるかもしれません。

ボーダー対応

// ボーダーを描画
const borderWidth = parseFloat(style.borderWidth) || 0;
if (borderWidth > 0) {
  ctx.strokeStyle = style.borderColor;
  ctx.lineWidth = borderWidth;
  ctx.strokeRect(x, y, w, h);
}

画像対応

if (element.tagName === 'IMG') {
  ctx.drawImage(element, x, y, w, h);
}

ただし、外部ドメインの画像はCORSの設定がないと canvas.toDataURL() 時にセキュリティエラーになるので注意が必要です。

html2canvasの限界

html2canvasにも限界があるようです。

  • iframe: 同一オリジンでないとDOMにアクセスできません
  • 外部画像: CORSヘッダーがないとCanvasが汚染され、toDataURL() がセキュリティエラーになります
  • 一部のCSS: backdrop-filtermix-blend-modefilter などはCanvas APIに対応する機能がなく再現できません
  • Webフォント: 読み込み完了前に描画すると反映されません

これらはhtml2canvasの実装の問題というより、ブラウザのセキュリティポリシー(Same-Origin Policy)やCanvas APIの仕様による制約です。公式ドキュメントにも記載があります。

まとめ

html2canvasの仕組みを簡易実装してみました。

  • ブラウザにはページを直接画像化するAPIがない
  • そのためDOMを走査してCanvasに描き直している
  • getComputedStyle()getBoundingClientRect() が肝
  • フルスペックで実装しようとするとブラウザのレンダリングエンジンの再実装に近い作業になる

実務ではhtml2canvasをそのまま使えば十分かと思いますが、仕組みを理解しておくと「なぜうまくいかないのか」の原因を特定しやすくなるかもしれません。

Discussion