0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SVGをPNG/JPEGに変換するツールをライブラリなしで作る——Canvas APIだけで完結させる実装と3つの罠

0
Posted at

「SVGをPNGに変換したい」というニーズは地味に多い。IllustratorやFigmaから書き出したSVGロゴをSNSに上げたい、SVG非対応のWordやPowerPointに貼りたい、OGP画像として高解像度PNGが欲しい——どれもラスター形式が必要になる場面だ。

変換サービスはいくつもあるが、ファイルをサーバーにアップロードするタイプが多い。ロゴやデザインデータをよそのサーバーに送るのは気が引ける。そこで、外部ライブラリを一切使わず、ブラウザ標準の Canvas API だけで SVG→PNG/JPEG 変換を完結させるツールをぱんだツールズの1機能として作った。

実装してみると「コア処理は数十行」なのに、ちゃんと動かすには踏むべき落とし穴がいくつかある。この記事ではその実装と、ハマりどころを3つ取り上げる。

変換のコア:Blob → Image → Canvas → Blob

SVG をラスター画像に変換する流れは、ブラウザの仕組みに乗るだけで済む。ポイントは「SVG を <img> に読ませて Canvas に描けば、あとはブラウザがレンダリングしてくれる」こと。専用ライブラリは要らない。

async function handleConvert() {
  if (!svgText) return

  // 1. SVGテキストをBlob化してObject URLを作る
  const blob = new Blob([svgText], { type: 'image/svg+xml' })
  const svgUrl = URL.createObjectURL(blob)

  // 2. Image要素に読み込む(onloadを待つ)
  const img = new Image()
  await new Promise<void>((resolve, reject) => {
    img.onload = () => resolve()
    img.onerror = () =>
      reject(new Error('SVGの読み込みに失敗しました。外部リソースを含む場合は変換できないことがあります。'))
    img.src = svgUrl
  })
  URL.revokeObjectURL(svgUrl)

  // 3. 指定サイズのCanvasに描画
  const canvas = document.createElement('canvas')
  canvas.width = outputWidth
  canvas.height = outputHeight
  const ctx = canvas.getContext('2d')
  if (!ctx) throw new Error('Canvas の初期化に失敗しました')

  if (outputFormat === 'jpeg') {
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
  }
  ctx.drawImage(img, 0, 0, outputWidth, outputHeight)

  // 4. Canvasをエンコードしてダウンロード用Blobにする
  const mimeType = outputFormat === 'jpeg' ? 'image/jpeg' : 'image/png'
  const qualityValue = outputFormat === 'jpeg' ? quality / 100 : undefined
  const resultBlob = await new Promise<Blob>((resolve, reject) => {
    canvas.toBlob(
      (b) => (b ? resolve(b) : reject(new Error('画像変換に失敗しました'))),
      mimeType,
      qualityValue
    )
  })
  // resultBlob をダウンロードさせる
}

やっていることは4ステップだけ。

  1. SVG テキストを Blob(MIME image/svg+xml)にして URL.createObjectURL() で URL 化する
  2. その URL を Image 要素に読ませ、onloadPromise でラップして待つ
  3. 出力サイズの <canvas> を作り、drawImage() で描く
  4. canvas.toBlob() で PNG / JPEG にエンコードする

ベクター → ラスターの変換そのものはブラウザのレンダラがやってくれる。だから「どんなに大きな出力サイズを指定しても輪郭が鮮明」になる。ラスター → ラスターの拡大(補間)とはここが決定的に違う。SVG は座標データなので、4096px に拡大しても元の数式から再ラスタライズされるだけだ。

以下、地味にハマる3つの罠。

罠1:JPEGの透過は「黒」になる——白背景で塗りつぶす

PNG は透過(アルファチャンネル)を持てるが、JPEG は透過を表現できない。透過 PNG をそのまま JPEG に変換すると、透過部分は で塗りつぶされる。ロゴを JPEG にしたら背景が真っ黒になった、というのはこの仕様によるもの。

対策はシンプルで、drawImage()前に Canvas 全体を白で塗っておく。

if (outputFormat === 'jpeg') {
  ctx.fillStyle = '#ffffff'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
}
ctx.drawImage(img, 0, 0, outputWidth, outputHeight)

順序が重要で、塗りつぶしは描画より先。後にすると SVG の上を白で塗りつぶしてしまう。PNG のときはこの処理を入れないことで透過を保持する、という出し分けにしている。

罠2:外部リソースを参照するSVGは描画できない(tainted canvas)

これが一番厄介。SVG の中で外部の Web フォントや外部画像(<image href="https://..."> など)を参照していると、ブラウザのセキュリティポリシーによって読み込みがブロックされる。

ローカルの SVG ファイルを Blob URL 経由で <img> に読ませた場合、その <img> から見て外部リソースはクロスオリジン扱いになる。ここから先はブラウザによって転び方が分かれる。フォントが読み込まれずデフォルトフォントで代替されたり、外部画像部分が空白になったりするケースと、クロスオリジンの画像が Canvas に描かれて Canvas が 汚染(tainted) されるケースだ。後者になると toBlob() / toDataURL()SecurityError を投げ、変換結果を取り出せなくなる。

このツールでは外部リソースつき SVG を完全対応させるのは諦めて、読み込み失敗を img.onerror で握り、ユーザーにメッセージで返すようにした。

img.onerror = () =>
  reject(new Error('SVGの読み込みに失敗しました。外部リソースを含む場合は変換できないことがあります。'))

確実に変換したいなら、SVG 側でリソースをインライン化しておくのが正解になる。

  • Web フォントは @font-facesrc に Base64 データ URI で埋め込む
  • 外部画像は <image>hrefdata:image/png;base64,... にする
  • 外部 CSS ファイル参照ではなく <style> でインラインに書く

要は「SVG ファイル1枚で自己完結している」状態にしておけばクロスオリジンが発生しない。デザインツールからの書き出し時に「フォントをアウトライン化」「画像を埋め込む」を選んでおくと安定する。

罠3:出力サイズの初期値は viewBox から取る

SVG には固有のピクセルサイズが「ない」ことがある。width="512" height="512" を持つものもあれば、viewBox="0 0 512 512" だけで width/height を持たないものもある。出力サイズのデフォルト値を妥当に決めるには、両方を見て優先順位をつける必要がある。

DOMParser で SVG をパースして、viewBoxwidth/height 属性 → フォールバックの順で寸法を拾う。

function parseSvgDimensions(svgText: string): SvgDimensions {
  const parser = new DOMParser()
  const doc = parser.parseFromString(svgText, 'image/svg+xml')
  const svg = doc.querySelector('svg')
  if (!svg) return { width: 512, height: 512 }

  // viewBox を最優先("minX minY width height" の3,4番目)
  const viewBox = svg.getAttribute('viewBox')
  if (viewBox) {
    const parts = viewBox.trim().split(/[\s,]+/)
    if (parts.length === 4) {
      const w = parseFloat(parts[2])
      const h = parseFloat(parts[3])
      if (w > 0 && h > 0) return { width: Math.round(w), height: Math.round(h) }
    }
  }

  // 次に width/height 属性
  const wAttr = svg.getAttribute('width')
  const hAttr = svg.getAttribute('height')
  if (wAttr && hAttr) {
    const w = parseFloat(wAttr)
    const h = parseFloat(hAttr)
    if (w > 0 && h > 0) return { width: Math.round(w), height: Math.round(h) }
  }

  return { width: 512, height: 512 }
}

viewBoxwidth/height 属性より優先するのは、width="100%" のような相対指定だと parseFloat() が意図しない値になりやすいから。viewBox の方が「本来のアスペクト比」を素直に表していることが多い。区切り文字はスペースとカンマが混在しうるので /[\s,]+/ で割る。

検出した寸法を出力サイズの初期値にしつつ、ユーザーが 1〜4096px の範囲で変えられるようにした。範囲は clampSize で機械的に丸める。

function clampSize(value: number): number {
  return Math.max(1, Math.min(4096, Math.round(value)))
}

アスペクト比ロックをオンにしたときは、片方を変えたら元の比率から自動で他方を計算する。

function handleWidthChange(value: number) {
  const clamped = clampSize(value)
  setOutputWidth(clamped)
  if (lockAspect && originalDimensions.width > 0) {
    const ratio = originalDimensions.height / originalDimensions.width
    setOutputHeight(clampSize(clamped * ratio))
  }
}

Object URLのリークを ref で確実に解放する

URL.createObjectURL() で作った URL は、明示的に revokeObjectURL() しない限りメモリに残り続ける。このツールではプレビュー用と変換結果用で複数の Object URL を作るので、解放漏れがそのままリークになる。

ファイルを差し替えるたび、再変換するたびに古い URL を捨て、コンポーネントのアンマウント時にも掃除する。最新の URL を useRef で保持しておくのがポイントで、useEffect のクリーンアップで state を参照すると古い値を掴むことがある。

const previewUrlRef = useRef<string | null>(null)
const resultUrlRef = useRef<string | null>(null)

useEffect(() => {
  return () => {
    if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
    if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current)
  }
}, [])

変換結果を toBlob()(Blob)で受けているのも理由がある。toDataURL() の Base64 文字列はサイズが約1.33倍に膨らむうえ、ダウンロード時に巨大な data URI を扱うことになる。Blob のまま持って URL.createObjectURL() でダウンロードリンクを作る方が、メモリ的にもダウンロード挙動的にも素直だ。

まとめ

SVG→PNG/JPEG 変換は、Canvas API の「<img>drawImage で描いて toBlob で取り出す」という基本に乗るだけで、専用ライブラリなしに実装できる。ベクターからのラスタライズはブラウザ任せでいいので、コア処理は本当に短い。

ただし実用ツールにするには、

  • JPEG の透過は黒落ちするので白背景で塗りつぶす
  • 外部リソース参照 SVG は CORS / tainted canvas でこける(インライン化が前提)
  • 出力サイズの初期値は viewBox 優先で拾う

あたりを押さえる必要がある。どれもブラウザの仕様に素直にぶつかる部分で、知っていれば数行で対処できるが、知らないと「なぜか背景が黒い」「なぜか変換できない」で詰まる。

実際に動かせるものをブラウザ完結で公開している。SVG をアップロードせずに変換したいときにどうぞ。

ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?