npm @akabeko/svg2png v1.0.0 release
@akabeko/svg2png という npm を開発した際の覚書。
開発にいたる背景
icon-gen の Issue として remove svg2png dependency が要望された。icon-gen では SVG から PNG を生成するために svg2png を利用している。しかしこれが SVG を描画するため依存している phantomjs-prebuilt は deprecated になっており、PhantomJS 自体もだいぶ前に開発を終了したようだ。
2020/8/27 現在の ariya/phantomjs を見るとリポジトリーはアーカイブされていないように見えるものの、将来はどうなるかわからない。よって PhantomJS の代わりに puppeteer-core 経由で Chromium を利用する私家版 svg2png を開発することにした。
設計方針
- Scoped packages にする
- 本家 svg2png と明確に区別する
- 私家版なら名前空間のつく Scoped packages が明示的でよいと判断した
- svg2png-puppeteer に対して svg2png-puppeteer-core にするか迷ったがやめた
- 開発言語は TypeScript
- 型の恩恵と VS Code 上の良質な開発体験を享受したい
- 配布 npm には d.ts を同梱して
@types
なしに型情報を提供する
- SVG 描画には Chromium を利用
- 本家 svg2png
- Chromium 参照と管理は puppeteer-core を利用
- puppeteer だと Chromium が npm に定義された revision に固定されてインストール時にダウンロードされる
- puppeteer-core はダウンロードと Chromium 参照を柔軟に管理できる
- ユーザー環境にインストール済みの Chrome (Chromoium) を指定することで時間のかかるダウンロードを避けられる
- 好みの revision とディレクトリーを指定してダウンロード可能、指定が一緒ならダウンロードの多重実行も避けられる
- 使用する Chromium が明示されることで冪等性を保証しやすくなる、ダウンロード済の Chromium を保持する限りは確実に
- CLI 対応
- CLI が使えると npm-scripts に組み込めたりして便利
- 本家 svg2png に対する優位性として複数サイズを一括出力できるようにしたい
Chromium 管理
Chromium のダウンロードには BrowserFetcher を利用する。基本的な処理は以下のような感じ。
import puppeteer, { RevisionInfo } from 'puppeteer-core'
import upath from 'upath'
const download = async (
revision: string,
path: string
): Promise<RevisionInfo> => {
const browserFetcher = puppeteer.createBrowserFetcher({ upath.resolve(path) })
return await browserFetcher.download(revision)
}
puppeteer.createBrowserFetcher([options])
へ指定する path
プロパティーは絶対パスにしなければならない。相対パスで browserFetcher.download(revision)
を呼び出した場合は Target directory is expected to be absolute
というエラーになる。
Node.js 標準の path にも注意が必要だ。Vivliostyle の Issue で知ったのだが Windows 環境でパスを処理する際、区切り文字のバックスラッシュをそのまま返す。そのためこれを解釈できない処理系に渡すとエラーになる。
現時点の svg2png としてはパス処理が内部完結しているため問題ない。しかし外部から呼ばれる svg2png
関数は戻り値として出力した PNG ファイルのパス配列を返すため、それを参照する際に別の処理系へ渡されるかもしれない。対策として Vivliostyle の修正と同様に upath を利用することにした。
browserFetcher.download(revision)
へ指定する revision は公式リファレンスによると omahaproxy.appspot.com を参照せよとある。しかし現時点で最新の安定版である 782793
を指定したら 404 エラーだった。puppeteer としての推奨なので svg2png もリファレンスにこれを掲載したが情報源として問題ありそう。
ちなみに browserFetcher.canDownload(revision)
で指定 revision がダウンロード可能なことを判定する際、782793
で OK だった。しかし実際には 404 エラーとなる。仕方がないので puppeteer がどうやって revision を決定しているのか調べたら puppeteer/revisions.ts を見つけた。どうやらこの定数が puppeteer として配信される際の Chromium revision らしい。実際、この値で試したらダウンロードに成功。
このようにダウンロード元として確実なものだけ列挙された資料がほしいものだ。もしご存知の方がいあれば @akabekobeko までお知らせください。
Chromium のダウンロード先と revision 管理について。
createBrowserFetcher([options])
の path
プロパティーを指定しない場合、ダウンロードされた Chromium は node_modules/puppeteer-core
内が選ばれる。指定してもしなくても処理に成功すれば同一 revision の多重ダウンロードは発生しない。実に便利だ。
Chromium の起動と終了
Chromium の「起動→ページ処理→終了」までの基本的な遷移は以下のようになる。
const convert = async (executablePath: string) => {
const browser = await puppeteer.launch({executablePath})
const page = await browser.newPage()
try {
// page を使った Chromium 処理
} ccatch (error) {
throw error
} finally {
await browser.close()
}
}
puppeteer.launch([options])
の executablePath
には Chromium の実行パスを指定。これは前述の browserFetcher.canDownload(revision)
の処理結果である RevisionInfo に含まれる。インストール済の Chrome のパスを指定してもよい。例えば
- macOS:
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
- Windows:
C:\\Program Files (x86)\\Google\\Chrome\\Application\\Chrome.exe
- 380177 - Chrome 64-bit is located in Program Files (x86) not Program Files - chromium により
Program Files (x86)
はProgram Files
へ変更される可能性あり
- 380177 - Chrome 64-bit is located in Program Files (x86) not Program Files - chromium により
となるだろう。Windows のパス区切り文字は前述の upath 説明で書いたとおり /
でもよさそうだ。\
はエスケープ文字でもあるため JavaScript などの文字列上では \\
としなければならない。/
ならそうせずに済む。
puppeteer.launch([options])
から得た Browser インスタンスが Chromium を表す。headless: false
(既定値) ではウィンドウとして表示されず Headless Chome となる。svg2png は GUI を持たないため、これでよい。
browser.newPage()
で Page インスタンスを得られる。これは Chrome 上の一つのタブと認識するとわかりやすい。ここにページを読み込んだり、その内容を取得したりしてゆく。
Chromium の終了させるには browser.close()
を呼び出す。これを忘れると Chromium のプロセスが残るなどの問題があるので注意すること。例外が発生しても確実に呼び出すため finally
ブロックを利用するのがよいだろう。
SVG 描画と PNG 出力
Page
インスタンスによる SVG 描画と PNG 出力は以下のようになる。
await page.setContent('<!DOCTYPE html> ...')
await page.setViewport({ width: 256, height: 256 })
// Explicitly fix the size of SVG tags.
// see: https://github.com/neocotic/convert-svg/blob/master/packages/convert-svg-core/src/Converter.js
await page.evaluate(
({ width, height }) => {
const elm = document.querySelector('svg')
if (!elm) {
return
}
if (typeof width === 'number') {
elm.setAttribute('width', `${width}px`)
} else {
elm.removeAttribute('width')
}
if (typeof height === 'number') {
elm.setAttribute('height', `${height}px`)
} else {
elm.removeAttribute('height')
}
},
{
width: 256,
height: 256
}
)
await page.screenshot({ path: './sample.png', omitBackground: true })
page.setContent(html[, options])
でメモリー上の HTML 文字列をページとして設定する。既存の Web ページまたはローカル HTML なら page.goto(url[, options])
を使用すること。svg2png は以下のように SVG 読み込んで組み合わせた HTML を設定している。
const createHTML = async (filePath: string): Promise<string> => {
const svg = await readFileAsync(filePath, 'utf8')
return `<!DOCTYPE html><style>html, body { margin: 0; padding: 0; } svg { position: absolute; top: 0; left: 0; }</style>${svg}`
}
page.setViewport(viewport)
でページの viewport
を動的に指定する。これは puppeteer.launch([options])
の defaultViewport
に既定値として指定することも可能。しかし複数サイズの PNG 出力を対応する際、Chromium の処理コストを軽減するために生成済みの Browser
と Page
インスタンスを流用したかったので動的指定 (更新) するようにした。
page.evaluate(pageFunction[, ...args])
はページに読み込まれた <svg>
へ明示的に viewport
と一致するサイズを指定するための処理。これをしないと SVG は見切れて描画される。サンプル コードのコメントに書いた参考処理を見て解決方法に気がついた。
ここまでの処理でページ全体に SVG が描画されたはず。あとはこれを PNG ファイルとして出力すればよい。この処理には page.screenshot([options])
を使用する。標準では PNG ファイルになるのだが透過させたいなら omitBackground: true
を指定すること。これを忘れるとページの背景 (標準では白) が合成される。
CLI
本家 svg2png に対して私家版は複数サイズの PNG 出力に対応した。これを CLI でどうやって実現するか。色々と検討した結果、パラメーターを配列として処理することにした。書式は以下。
--sizes [256,[24,32],...]
最外周は必ず配列とする。この仕様だと単一サイズを指定する際も []
を必要とするが解析が楽になるし、ユーザーに複数サイズの指定が可能なことを明示できるから良しとした。要素は Number
or Number[]
。正方形なら Number
、長方形は要素数 2 で [width,height]
をそれぞれ指定する。
CLI パラメーターのスペース区切りを回避するため、詰めて書く必要があって読みにくいかもしれない。ただこれは CLI としての前提知識であり、事情として察してもらえると判断した。
これ以外の部分については commander 任せ。
その他
npm プロジェクトの README には Version Badge で生成したバッヂを掲載している。しかし手抜きして過去プロジェクトのものを流用して npm 名だけ変更していた。
今回もこれで問題ないだろうと楽観していたのだが、Scoped packages は npm 名が @akabeko/svg2png
となるためエスケープが必要だった。v1.0.0 を npm publish
した際はこれに気づかず
[![npm version](https://badge.fury.io/js/.svg)](https://badge.fury.io/js/@akabeko/svg2png)
としており npmjs 上だとバッヂが表示されない。正しくはエスケープして以下となる。
[![npm version](https://badge.fury.io/js/%40akabeko%2Fsvg2png.svg)](https://badge.fury.io/js/%40akabeko%2Fsvg2png)
GitHub 上では修正した。これだけのために再 publish はしない。他に svg2png としての変更があればそのリリース時に反映されるだろう。
まとめ
開発のきっかけになった Issue 登録は今年の 1 月。しかしその時点ではやる気が出ず、お茶を濁すコメントをしてずっと放置していた。phantomjs-prebuilt が deprecated なのとサイズの巨大さは確かに問題だが、
- 正常に動作している
- SVG から PNG を生成する処理は npm 内の依存関係のみで完結している
という理由から手を付ける気になれなかった。特に 2 は非常に重要。私は環境依存が増えることを好まない。npm として配布するなら npm エコ システムだけで環境を完結するのが第一と考える。そのため本家 svg2png の phantomjs-prebuilt 依存を支持していた。
しかし puppeteer-core の存在を知り、環境構築を npm の機能で完結可能なら乗り換えてもよいのでは?と考えるようになった。むしろ利用する Chromium を明示的に指定できるのはメリットではなかろうか。そのうえ Chromium のインストールを npm install
と切り離すことで自身の npm 配布サイズも小さくできる。
というわけで puppeteer-core のお試しも兼ねて svg2png 部分を私家版として開発してみた。結果として puppeteer-core の優れた設計や便利さを実感できて非常によかった。
Chromium の機能を利用したいが Electron アプリとして開発するほどでもない、みたいな用途によさそう。開発の選択肢がひとつ増えた。