「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ステップだけ。
- SVG テキストを
Blob(MIMEimage/svg+xml)にしてURL.createObjectURL()で URL 化する - その URL を
Image要素に読ませ、onloadをPromiseでラップして待つ - 出力サイズの
<canvas>を作り、drawImage()で描く -
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-faceのsrcに Base64 データ URI で埋め込む - 外部画像は
<image>のhrefをdata:image/png;base64,...にする - 外部 CSS ファイル参照ではなく
<style>でインラインに書く
要は「SVG ファイル1枚で自己完結している」状態にしておけばクロスオリジンが発生しない。デザインツールからの書き出し時に「フォントをアウトライン化」「画像を埋め込む」を選んでおくと安定する。
罠3:出力サイズの初期値は viewBox から取る
SVG には固有のピクセルサイズが「ない」ことがある。width="512" height="512" を持つものもあれば、viewBox="0 0 512 512" だけで width/height を持たないものもある。出力サイズのデフォルト値を妥当に決めるには、両方を見て優先順位をつける必要がある。
DOMParser で SVG をパースして、viewBox → width/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 }
}
viewBox を width/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 にも同じ内容を投稿しています。