背景
SVGファイルをビットマップファイルに変換するための方法がいくつかあります。
- ImageMagickまたはGraphicsMagickを使って画像フォーマットを変換する
- InkscapeなどのSVGエディタでビットマップファイルとして出力する
それぞれの方法にはそれぞれ次の欠点があります。
- ImageMagickは、SVGファイル中のpattern要素に指定されたviewboxによる縮小が無視するため、SVGファイルによっては、期待通りに変換できないことがある
- Inkscapeは、ビットマップファイルを直接出力できないため、一度PNG等の形式で出力してから、別のツールでビットマップファイルに変換する必要がある
結論
node-canvasを使って、SVGファイルにからビットマップファイルを生成するnodeプログラムを作成します。
プログラムは次の2つの処理からなります。
- node-canvasをつかってSVGファイルを画像データに変換
- 画像データをビットマップフォーマットで保存
既知の制約
- 600x600の画像を変換するのに800〜900ms掛かります。大量のファイルを処理するには、厳しいかもしれません。
- 32ビットビットマップを生成します。24ビットビットマップにできれば出力ファイルサイズは小さくなります。
捕捉
実装してから気が付きました。
oslllo/svg2: Convert a SVG to multiple image formats (without puppeteer or a headless browser)を使うと、SVGファイルをビットマップファイルに変換できます。
課題
ImageMagickを使ったSVGファイルの変換
ImageMagickでは一部のSVGファイルにの変換が期待通りに行きません。
例えば次のSVGファイルがあります。
<svg class='_onImage' height='600' width='600' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns='http://www.w3.org/2000/svg'>
<pattern height='363' id='box' patternTransform='translate(303, 0)' patternUnits='userSpaceOnUse' style='fill:none;stroke:black;stroke-width:1' viewBox='0 0 804 1452' width='201'>
<g id='green_yellow_orange' style='stroke:#281824'>
<polygon points='9 924 203 814 396 923 204 1032' style='fill:#275d50;stroke-width:2'/>
<polygon points='4 927 201 1038 201 1281 4 1174' style='fill:#F9c057;stroke-width:4'/>
<polygon points='-1 1018 131 1093 132 1244 0 1174' style='fill:none;stroke-width:7'/>
<polygon points='2 1067 91 1118 90 1221 3 1172' style='fill:#f38c03;stroke-width:7'/>
<polygon points='206 1039 401 929 399 1171 207 1279' style='fill:#F9c057;stroke-width:1'/>
<polygon points='274 1094 406 1019 402 1166 275 1248' style='fill:none;stroke-width:7'/>
<polygon points='316 1117 403 1069 403 1172 315 1222' style='fill:#f38c03;stroke-width:7'/>
</g>
<use xlink:href='#green_yellow_orange' transform='translate(201, -1089)'/>
<use xlink:href='#green_yellow_orange' transform='translate(402, -726)'/>
<use xlink:href='#green_yellow_orange' transform='translate(603, -363)'/>
<use xlink:href='#green_yellow_orange' transform='translate(-201, -363)'/>
<use xlink:href='#green_yellow_orange' transform='translate(201, 363)'/>
<g id='yellow_green' style='stroke:#281824;stroke-width:1'>
<polygon points='411 922 604 814 797 922 603 1031' style='fill:#f9c059'/>
<polygon points='407 929 600 1038 601 1279 407 1169' style='fill:#178e68'/>
<polygon points='607 1038 800 929 800 1171 608 1279' style='fill:#178e68'/>
</g>
<use xlink:href='#yellow_green' transform='translate(-601, 364)'/>
<use xlink:href='#yellow_green' transform='translate(199, 362)'/>
<use xlink:href='#yellow_green' transform='translate(-200, -362)'/>
<use xlink:href='#yellow_green' transform='translate(-401, -725)'/>
<use xlink:href='#yellow_green' transform='translate(-602, -1090)'/>
<use xlink:href='#yellow_green' transform='translate(202, -1089)'/>
</pattern>
<rect height='100%' style='fill:white' width='100%'/>
<rect height='100%' style='fill:#892945' width='51%'/>
<rect height='100%' style='fill:#281824' width='50%' x='51%'/>
<rect height='100%' style='fill:url(#box)' width='297' x='303'/>
</svg>
このSVGをImageMagickをつかって変換してみましょう。
ImageMagickのバージョンは 7.0.10-24
です。
convert giyuu.svg out.bmp
次の見た目になります。
Qiitaではビットマップファイルは添付できません。PNG形式に変換したものを添付しています。
期待する見た目は次です。
何かが違います。
SVG中のpattern要素のviewboxの指定が無視されています。
SVGのpattern要素は次です。
<pattern
height='363'
id='box'
patternTransform='translate(303, 0)'
patternUnits='userSpaceOnUse' style='fill:none;stroke:black;stroke-width:1'
viewBox='0 0 804 1452'
width='201'>
width属性とheight属性はパターンの表示サイズを、viewBox属性はパターン内の座標のサイズを指定します。width='201'
、height='363'
に対し、viewBox='0 0 804 1452'
を指定しています。widthとheightに対してviewBoxの値は4倍です。この場合、パターン中の座標は、1/4に縮小されて表示されます。
こうすると、1以下の小数点を表示するパターンを定義するときに、パターン定義内では整数値で座標を指定できます。SVGファイルを書くときによく使われるテクニックかどうかは知りませんが、SVGとしてはvaildです。
ImageMagickに渡すオプションを指定すると、対応できるのかもしれません。しかし、ImageMagickのような汎用的な画像処理ライブラリに、SVGのviewBoxの扱いを変更するオプションなどあるのでしょうか?
また、過去のviewBoxの扱いに対する反応はいまいち芳しくありません。
ImageMagickを使う方法は諦めます。
GraphicsMagickを使ったSVGファイルの変換
GraphicsMagickのバージョンは1.3.35
です。
gm convert giyuu.svg out.bmp
次の見た目になります。
非常に残念な感じがします。
Canvasデータの読み込み
SVGを画像データに変換する手法に、ブラウザのCanvas APIを使う手法が知られています。
HTMLファイル上にcanvasタグとimgタグを用意します。
imgタグのsrc属性にはsvgファイルを指定します。
次のJavaScriptを実行します。
const canvas = document.querySelector('#canvas')
const ctx = canvas.getContext('2d')
const img = document.querySelector('#img')
context.drawImage(img, 0, 0)
context.getImageData(0, 0,600, 600).data
canvasにimgタグを書き込むとSVGファイルの画像データが取得できます。
この手法にはいくつか不便な点があります。
- window.onloadを使うなど、SVGファイルの読み込みを待つ必要がある
- 画像1枚毎にブラウザを起動するのは、大げさ過ぎる
- ブラウザのセキュリティ機能を回避するためにHTTPサーバーを起動する必要がある
最後の点を詳しく説明します。
ブラウザ上のCanvasはクロスドメインの画像データを返すことができません。
Google Chromeで、上記手法をつかったHTMLファイルをブラウザで読み込むと次のようなエラーが起きます。
Uncaught DOMException: Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data.
これを回避するためにはpython -m SimpleHTTPServer
等をつかってHTTPサーバーを起動します。HTTPサーバーから取得したファイルであれば、ブラウザは同一ドメインと判定します。
これらの不便な点を回避するために、ブラウザなしで、Canvasをつかいたいです。
Node.jsでCanvas(ImageData)を使った簡単な画像処理 - Qiitaを参考にすると、node-canvasという、Node.jsで動作するCanvasの実装があります。
幸運にもnode-canvasはSVGをサポートしています。
これを使います。
次のJavaScriptを実行するとSVGファイルのcanvasImageData
が取得できます。
const { createCanvas, loadImage } = require("canvas");
function getCanvasImageData(image) {
const canvas = createCanvas(image.width, image.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
return ctx.getImageData(0, 0, image.width, image.height);
}
!(async function () {
const image = await loadImage('giyuu.svg');
const canvasImageData = getCanvasImageData(image);
}()
Canvasからビットマップファイルへの変換
Canvas convert to Bitmap BLOB - RAKUGAKI PROGRAMERS に、Canvasからビットマップへの変換ロジックがあります。参照先のリンクが切れていますので、ビットマップファイルのフォーマットは次のサイトの情報を参考にしました。
これらの情報によると、書き出すビットマップフォーマットは次のものです。
- Windowsビットマップファイル
- ピクセルのビット数 32
- 圧縮タイプ 0 (無圧縮)
- カラーパレットを使わない
画像データを圧縮したり、パレットを使えば、ロジックは複雑になります。
おそらく、書き出すプログラムを書くのが一番簡単なフォーマットだと思います。
初めて書くのにちょうど良いので、このままのロジックを使います。
画像データの開始位置が56
になっていたので、54
に移動しました。
元のプログラムはヘッダと画像データに一つのArrayBufferインスタンスを用意し、画像データはUint32Array
をつかってオフセットをつけて書いていました。Uint32Array
は4バイト単位の操作しかできません。54
のような2バイトのオフセットがつけるため、バッファをヘッダ用と画像用に分けました。
32bitの画像データは次のように書きます。
- 1画素あたり32bit(4 byte)
- Blue(8bit)、Green(8bit)、Red(8bit)、Reserved(8bit)の順番
- Reservedには'0'固定
例えば、CanvasのImageDataの値が[137, 41, 69, 255]
だとしたら0x45 0x29 0x890 x00
になります。32bitの色情報を格納するにはUint32Array
を使うのが便利です。
画像データの書き込みのロジックは次のようになりました。
// write image data
const bodyData = new Uint32Array(canvasImageData.length);
// ある行を左から右に進んで行く
for (var x = 0; x < width; x++) {
// 上から下に行を進んで行く
for (var y = 0; y < height; y++) {
// y行目のx列の位置
// canvasのimageDataは1バイトごとに分かれている、画素単位の4バイトずつ進みます。
const i = ((y * width) + x) * 4;
// ビットマップは左下から右上に記録されているので、下から詰めていく
const j = (width * (height - y)) - (width - x);
bodyData[j] =
(canvasImageData[i + 0] << 16) // Red
| (canvasImageData[i + 1] << 8) // Green
| (canvasImageData[i + 2]) // Blue
}
}
Canvas2Bmp.js
ビットマップフォーマットのヘッダ情報を書き込む処理を加え、次のようなCanvsの情報をビットマップフォーマットに変換するプログラムを書きました。
ヘッダの情報は2バイトと4バイトの2種類あるので、DataViewをつかって書き込みました。
const headerSize = 54; // ファイルヘッダ(14byte) + ファイル情報ヘッダ(40byte)= 54byte 固定
// http://www.umekkii.jp/data/computer/file_format/bitmap.cgi
// https://www.ruche-home.net/program/bmp/struct
function fillFileHeader(dv, fileSize, headerSize) {
// bfType ファイルタイプ BM固定
dv.setUint8(0x0, "BM".charCodeAt(0));
dv.setUint8(1, "BM".charCodeAt(1));
dv.setUint32(2, fileSize, true); // bfSize ファイルサイズ
dv.setUint16(6, 0); // bfReserved1 予約領域 0固定
dv.setUint16(8, 0); // bfReserved2 予約領域 0固定
dv.setUint32(10, headerSize, true); // bfOffBits ファイルの先頭から画像データまでのオフセット[byte]
}
// 情報ヘッダ INFOタイプ
function fillImageHeader(dv, width, height, bodySize) {
dv.setUint32(14, 40, true); // biSize 情報ヘッダサイズ INFOタイプでは 40
dv.setUint32(18, width, true); // biWidth 画像の幅[ピクセル]
dv.setUint32(22, height, true); // biHeight 画像の高さ[ピクセル]
dv.setUint16(26, 1, true); // biPlanes プレーン数 1固定
dv.setUint16(28, 32, true); // biBitCount 色ビット数[bit] 1, 4, 8, 16, 24, 32
dv.setUint32(30, 0, true); // biCompression 圧縮形式 0, 1, 2, 3
dv.setUint32(34, bodySize, true); // biSizeImage 画像データサイズ[byte]
dv.setUint32(38, 0, true); // biXPixPerMeter 水平解像度[dot/m] 0で良さそう
dv.setUint32(42, 0, true); // biYPixPerMeter 垂直解像度[dot/m] 0で良さそう
dv.setUint32(46, 0, true); // bitClrUsed 格納パレット数[使用色数]
dv.setUint32(50, 0, true); // bitClrImportant 重要色数
}
module.exports = class Canvas2Bitmap {
constructor(canvasImageData) {
this._canvasImageData = canvasImageData;
}
get _width() {
return this._canvasImageData.width;
}
get _height() {
return this._canvasImageData.height;
}
get _header() {
const width = this._width;
const height = this._height;
const bodySize = width * height * 4; // 色ビット数は32ビット(4byte)決め打ち
const fileSize = headerSize + bodySize;
const dv = new DataView(new ArrayBuffer(headerSize));
fillFileHeader(dv, fileSize, headerSize);
fillImageHeader(dv, width, height, bodySize);
return new Uint8Array(dv.buffer);
}
get _body() {
const data = this._canvasImageData.data;
const bodyData = new Uint32Array(data.length);
// ある行を左から右に進んで行く
for (var x = 0; x < this._width; x++) {
// 上から下に行を進んで行く
for (var y = 0; y < this._height; y++) {
// y行目のx列の位置
// canvasのimageDataは1バイトごとに分かれている、画素単位の4バイトずつ進みます。
const i = (y * this._width + x) * 4;
// ビットマップは左下から右上に記録されているので、下から詰めていく
const j = this._width * (this._height - y) - (this._width - x);
// 32bitビットマップ
// 1画素あたり32bit(4byte)で、Blue(8bit)、Green(8bit)、Red(8bit)、Resered(8bit)の順番で色の値が記録される。Reseredには'0'が入る。
// 137, 41, 69, 255だとしたら?
// 0x45, 0x29, 0x89, 0x00
bodyData[j] =
(data[i + 0] << 16) | // Red
(data[i + 1] << 8) | // Green
data[i + 2]; // Blue
}
}
return new Uint8Array(bodyData.buffer);
}
// WebでもNodeでも扱いやすい、Uint8Arrayを返します。
get buffer() {
const buffer = new Uint8Array(headerSize + this._canvasImageData.data.length * 4);
buffer.set(this._header, 0);
buffer.set(this._body, headerSize);
return buffer;
}
};
実装
node-canvasとCavas2Bmp.jsを組み合わせると次のようなNode.jsプログラムになります。
const { createCanvas, loadImage } = require("canvas");
const Canvas2Bitmap = require("./Canvas2Bitmap")
const fs = require("fs");
function getCanvasImageData(image) {
const canvas = createCanvas(image.width, image.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
return ctx.getImageData(0, 0, image.width, image.height);
}
!(async function () {
const image = await loadImage(process.argv[2]);
const canvasImageData = getCanvasImageData(image);
const bitmap = new Canvas2Bitmap(canvasImageData)
const stream = fs.createWriteStream('out.bmp');
stream.write(bitmap.buffer);
stream.end();
})();
次のように実行します。
node . giyuu.svg
out.bmp
が出力されます。
実は、SVGファイル以外にも、node-canvasのサポートしているpng、 jpeg、 gif形式のファイルも変換可能です。
参考
ついでに調べた、NodeでSVGを扱う情報を残しておきます。
SVG2
node-canvasをつかって、SVGファイルを画像データに変換します。
jimpとbmp-jsをつかって、24ビットビットマップに変換しています。
出力ファイルサイズが小さい点が優秀です。
どういうわけか600x600の画像を変換するのに1.5sほど掛かります。
バイナリ書き出しにはBufferをつかいます。
- oslllo/svg2: Convert a SVG to multiple image formats (without puppeteer or a headless browser)
- oliver-moran/jimp: An image processing library written entirely in JavaScript for Node, with zero external or native dependencies.
- shaozilee/bmp-js: A pure javascript BMP encoder and decoder for node.js
gen-jsbmp
canvasからビットマップフォーマットに変換します。
SVGファイルからcanvasへの変換は終わっている想定です。
ピクセルのビット数に1, 2, 4, 8, 16, 24, 32が選択可能です。
出力はBASE64エンコード文字列です。
BASE64へのエンコードですので、バイナリでなく文字列として編集します。
ブラウザ向けメインですが、Nodeでも動くと思います。
BMP.js
ブラウザ向けです。
canvasからビットマップフォーマットに変換します。
SVGファイルからcanvasへの変換は終わっている想定です。
ピクセルのビット数に1, 4, 8, 24を自動設定します!
バイナリ書き出しにはUint8Arrayをつかいます。
aタグを生成してclickする自動ファイルダウンロード機能もあります。
- ビットマップファイルを生成する[BMP.js]
- TakeshiOkamoto/BMP.js: Output images in JavaScript as BMP format. BMPファイルの作成。
svg2png
PhantomJSというヘッドレスブラウザをつかって、SVGファイルを画像データに変換します。
出力フォーマットはその名の通りPNGです。
SVGファイルだけを表示するHTMLをPhantomJSに渡して、PhantomJSのスクリーンショットを取る機能でPNGに変換しているようです。
saveSvgAsPng
ブラウザむけです。
canvasをつかってSVGファイルを画像データに変換します。
canvasのtoDataURLをつかってPNGに変換します
Draw Bitmap From Int Array.js
イメージデータをビットマップフォーマットに変換するJavaScriptサンプルです。
gen-jsbmp と似ています。