PerfectVector
著者: Irene Kim

SVGからCanvasへ:PNG書き出し前に画像を読み込む

Image.decodeでSVG全体を読み込み、選んだビットマップサイズで描画。CORSとPNGの読み取りを検証し、編集可能な元データと書き出しピクセルを分けます。

4 分で読める
目次

SVG全体をcanvasに描くには、画像として読み込み、image.decode()を待ってから、その画像をdrawImage()に渡します。その後、PNGの書き出しを別に確認してください。画像がcanvasに見えていても、ブラウザーがピクセルの読み取りや保存をブロックしている場合があります。

この経路はSVGドキュメントを画像として描きます。canvasに編集可能なSVGパスを渡すものではありません。選んだ輪郭を再構築したい場合は、Path2DのSVGガイドを使ってください。どの表示経路を選んでも、元のSVGを保管します。

デコード、描画、書き出しを分ける

HTMLImageElementは、canvasのdrawImage()が受け取れる入力です。出力先の幅と高さが、描画サイズを制御します。Image.decode()は、画像のデコード完了時に解決するPromiseを返します。リクエストの失敗やデコードの問題で拒否される場合があります。

デコード成功は最初の確認にすぎません。CORSの許可なしで読み込んだクロスオリジン画像は、描けてもcanvasを汚染します。その後のピクセル読み取りや書き出しは失敗します。MDNのCORS対応画像ガイドが、この違いを説明しています。

元データを残し、ピクセルを確認する
中空のひし形と円を、元の輪郭、滑らかな描画画像、ピクセル格子の出力として示す概念図
模式図:元の輪郭と描画したピクセルは異なる表現です。枠、矢印、粗い格子は概念的なもので、以下のブラウザー結果は、独立して実行した自作のテストファイルに基づきます。

自己完結した独自のSVGを作る

これをmaster.svgとして保存します。一つのパスと一つの円を含み、埋め込み画像、フォント、外部依存はありません。元データでは、内側のひし形と周囲のキャンバスは透明です。

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
  <path d="M96 24 L152 80 L96 136 L40 80 Z M96 58 L118 80 L96 102 L74 80 Z" fill="#176b5b" fill-rule="evenodd"/>
  <circle cx="200" cy="80" r="24" fill="#d4a53a"/>
</svg>

二つの図形とviewBoxを含めた、ファイル全体を画像として使います。パスのd値だけを取り出す方法とは異なり、ブラウザーのSVG画像レンダラーにドキュメントを描画させます。

この例は意図的に自己完結させています。画像として使うSVGには、外部リソースやスクリプトなどの制約があります。MDNの画像としてのSVGガイドを参照してください。この小さなファイルのデコード成功を、任意のSVGが外部フォント、スタイル、リンクしたアートワークを読み込む証拠と考えないでください。提供方法の選択には、インラインSVGと画像要素の比較が役立ちます。

CORS応答が異なる二つのローカルオリジンを動かす

オリジンにはポートも含まれます。以下のローカル専用サーバーは、同じSVGバイトを二つのオリジンで提供します。二つ目のオリジンでは、/cors/master.svgはAccess-Control-Allow-Origin: *を含み、/plain/master.svgは含みません。これで、アートワークと応答の許可を切り分けます。

これをSVGと以下のHTMLの隣に、serve-proof.pyとして保存します。python3 serve-proof.pyを実行し、http://127.0.0.1:8770/starter.htmlを開きます。両方のポートが空いている必要があります。変更する場合は、サーバーとHTMLを一緒に更新してください。ターミナルでEnterを押すと、両サーバーが停止します。

from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from threading import Thread
 
root = Path(__file__).parent
class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(root), **kwargs)
    def do_GET(self):
        if self.path in ['/cors/master.svg', '/plain/master.svg']:
            payload = (root / 'master.svg').read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'image/svg+xml')
            self.send_header('Cache-Control', 'no-store')
            if self.path.startswith('/cors/'):
                self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            self.wfile.write(payload)
        else:
            super().do_GET()
 
servers = [ThreadingHTTPServer(('127.0.0.1', port), Handler) for port in (8770, 8783)]
for server in servers:
    Thread(target=server.serve_forever, daemon=True).start()
try:
    input('Original SVG proof on 127.0.0.1:8770 and :8783. Enter to stop.\n')
finally:
    for server in servers:
        server.shutdown()
        server.server_close()

この許可範囲の広いヘッダーは、自作のアートワークを提供するローカルテスト用です。本番の画像ホストでは、自分が管理するサーバーで、適切な許可オリジンを設定してください。画像属性を設定しても、別のサーバーの内容へのアクセスは許可されません。

描画の前に画像をデコードする

この完全なページをstarter.htmlとして保存します。各ケースに別のcanvasを用意するので、汚染されたケースが成功ケースを汚染しません。CORSが必要なリクエストでは、MDNのcrossOriginリファレンスの説明どおり、srcより前にcrossOriginを設定します。

canvasのビットマップを480 × 320、CSS表示サイズを240 × 160に明示しています。元の画像の固有寸法は240 × 160で、drawImage()が選んだビットマップ寸法へ拡大縮小します。これは選択した寸法で、デバイスピクセル比率の計測ではありません。

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Original SVG to canvas export</title>
<style>
  body { margin: 24px; font: 16px system-ui; background: #f6f2e8; }
  main { display: grid; grid-template-columns: repeat(2, 280px); gap: 20px; }
  figure { margin: 0; }
  canvas { display: block; width: 240px; height: 160px; background: white; }
  figcaption { margin: 6px 0; }
  pre { white-space: pre-wrap; font-size: 12px; }
</style>
<h1>Decode, paint, then check PNG access</h1>
<main id="cases"></main>
<pre id="report"></pre>
<script>
  function pngBlob(canvas) {
    return new Promise((resolve, reject) => {
      try { canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Empty PNG')), 'image/png'); }
      catch (error) { reject(error); }
    });
  }
  function dataURL(blob) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = () => reject(reader.error);
      reader.readAsDataURL(blob);
    });
  }
  async function render(test) {
    const figure = document.createElement('figure');
    const canvas = document.createElement('canvas');
    canvas.width = 480;
    canvas.height = 320;
    const caption = document.createElement('figcaption');
    figure.append(canvas, caption);
    document.getElementById('cases').append(figure);
    const result = { id: test.id, url: new URL(test.src, location.href).href,
      crossOrigin: test.crossOrigin ?? null, canvasWidth: canvas.width, canvasHeight: canvas.height };
    const image = new Image();
    if (test.crossOrigin) image.crossOrigin = test.crossOrigin;
    image.src = result.url;
    try { await image.decode(); }
    catch (error) {
      result.stage = 'decode';
      result.error = error.name;
      caption.textContent = test.id + ': decode ' + error.name;
      return result;
    }
    result.naturalWidth = image.naturalWidth;
    result.naturalHeight = image.naturalHeight;
    const context = canvas.getContext('2d');
    context.drawImage(image, 0, 0, canvas.width, canvas.height);
    result.stage = 'painted';
    try {
      result.opening = [...context.getImageData(192, 160, 1, 1).data];
      result.fill = [...context.getImageData(192, 80, 1, 1).data];
      result.corner = [...context.getImageData(0, 0, 1, 1).data];
    } catch (error) { result.readbackError = error.name; }
    try {
      const blob = await pngBlob(canvas);
      result.pngType = blob.type;
      result.pngBytes = blob.size;
      result.pngDataURL = await dataURL(blob);
      result.export = 'ok';
      const download = document.createElement('a');
      download.href = result.pngDataURL;
      download.download = test.id + '.png';
      download.textContent = 'Save PNG';
      figure.append(download);
    } catch (error) { result.export = 'failed'; result.exportError = error.name; }
    caption.textContent = test.id + ': ' + result.stage + ', PNG ' + result.export;
    return result;
  }
  async function run() {
    const tests = [
      { id: 'same-origin', src: 'master.svg' },
      { id: 'cors-allowed', src: 'http://127.0.0.1:8783/cors/master.svg', crossOrigin: 'anonymous' },
      { id: 'no-cors-image', src: 'http://127.0.0.1:8783/plain/master.svg' },
      { id: 'cors-denied', src: 'http://127.0.0.1:8783/plain/master.svg', crossOrigin: 'anonymous' },
      { id: 'missing-file', src: 'missing.svg' },
    ];
    const results = [];
    for (const test of tests) results.push(await render(test));
    document.getElementById('report').textContent = JSON.stringify({ pageOrigin: location.origin, results }, (key, value) => key === 'pngDataURL' ? undefined : value, 2);
  }
  run();
</script>
</html>

書き出し関数は、投げられた例外と、nullのblob結果の両方を処理します。Canvas.toBlob()はPNG出力に対応し、canvasがオリジンクリーンでない場合にSecurityErrorを投げることがあります。成功した二つのケースでは、PNG保存用リンクを追加します。表示される報告ではbase64の内容を省いています。

SVGを変える前に失敗した段階を読む

このひな形をそのまま一つのChromeブラウザー環境で実行し、保存した二つのPNGを開き直しました。この観察は、自己完結した元のファイルと、この二つのローカルオリジンについてのものです。

ケース画像リクエスト観察した描画・読み取り・書き出し
同一オリジンローカルSVG、CORS属性なし240 × 160でデコード、描画、読み取りとPNGが成功
クロスオリジン、許可ありanonymousと応答ヘッダーデコード、描画、読み取りとPNGが成功
クロスオリジン、CORSリクエストなし属性なし、応答ヘッダーなしデコードと描画は成功、読み取りと書き出しがSecurityErrorを投げる
クロスオリジン、許可なしanonymous、応答ヘッダーなしEncodingErrorでデコードが拒否され、描画は試行されない
ファイルなし同一オリジンの404EncodingErrorでデコードが拒否され、描画は試行されない

CORSなしのケースでも、アートワークは見えました。しかし、その見た目は読み取りや書き出しの許可を示していません。CORS拒否とファイルなしのケースは前の段階で失敗したため、形状を書き直しても、どちらのリクエスト失敗も解決しません。

二つの書き出しPNGは、どちらも480 × 320で開き直せました。この実行では、バイト単位で同一で、それぞれ10,509バイトでした。ビットマップ座標(192,160)の開口部はRGBA (0,0,0,0)で、角のアルファもゼロでした。(192,80)のティール色サンプルは(23,107,91,255)でした。これらのサンプルは、このファイルの開口部と塗りを確認するもので、すべての縁ピクセルやSVG機能を確認するものではありません。

ブラウザーページの白い長方形は、canvasの後ろのCSS背景です。PNGに加えた白い塗りではありません。不透明な背景が必要なら、画像を描く前に背景を明示的に描き、書き出したファイルをもう一度確認してください。

編集可能なマスターとPNGを分ける

保存したPNGはピクセルを含みます。元のパス、円、編集可能な内側の輪郭は持ちません。master.svgをベクターの元データとして保管し、実際にPNGが必要な利用先に合わせて、canvasのビットマップ寸法を選んでください。既存のPNGを後から拡大しても、元の形状は復元されません。

また、SVGというファイル名だけでは、中のすべてのアートワークがベクター形状である証明にはなりません。リンク画像や埋め込み画像を含む場合は、埋め込みラスターの確認ガイドを使ってください。

元のアートワークがラスター画像でしか存在せず、編集可能な輪郭が必要な場合は、PerfectVectorの画像からベクターへの手順でSVGマスターを準備できます。この読み込み処理に使う前に、輪郭、色、開口部を確認してください。ベクター化はCORSヘッダーを修正したり、canvasの読み取り許可を与えたりしません。

よくある質問

SVGマークアップをdrawImage()に直接渡せますか? 渡せません。この例は、SVGファイルをHTMLImageElementへ読み込み、デコードした画像をdrawImage()へ渡します。マークアップ文字列は、その画像オブジェクトではありません。ドキュメントの読み込みと、Path2Dのパスデータによる構築を分けてください。

画像が表示されるのにPNGの書き出しが失敗するのはなぜですか? CORSの許可なしで読み込んだクロスオリジン画像は、描画後にcanvasを汚染する場合があります。このCORSなしのケースでは描画は成功しましたが、ピクセル読み取りと書き出しは両方ともSecurityErrorを投げました。SVG形状が壊れていると考えず、リクエストモードとサーバー応答を確認してください。

crossOrigin anonymousはすべての画像URLを直しますか? 直しません。srcより前に設定し、画像サーバーが互換性のあるCORS応答を提供する必要があります。同じアートワークでも、許可ヘッダーのないエンドポイントへanonymousを要求すると、デコードに失敗しました。

canvasをPNGとして書き出すと編集可能なSVGパスが残りますか? 残りません。PNGはラスター出力です。パス、図形、開口部を編集する必要があるなら、元のSVGを別に保管し、予定の納品サイズでPNGを確認してください。

出典

  1. MDN:CanvasRenderingContext2D.drawImage() — 対応する画像オブジェクト、出力先のサイズ、固有寸法。
  2. MDN:HTMLImageElement.decode() — 画像準備のPromiseと、リクエストまたはデコードの拒否。
  3. MDN:CORS対応画像 — 描画画像の読み取りと書き出しに対するオリジンクリーンの制約。
  4. MDN:HTMLImageElement.crossOrigin — 画像読み込みのCORSリクエストモード。
  5. MDN:HTMLCanvasElement.toBlob() — PNG blobの生成、nullの出力、SecurityError。
  6. MDN:画像としてのSVG — SVGの画像コンテキストと外部リソースの制約。

ラスターの元画像から、編集可能なアートワークを作りたいですか。PerfectVectorでSVGマスターを作成し、輪郭と開口部を確認してください。そのマスターを保管し、canvas描画と書き出したPNGを別々に確認します。

ブログのその他の記事

PerfectVector

編集しやすい
よりきれいなSVGから始めましょう

クレジットカード不要