アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

npm @akabeko/svg2png v1.0.0 release

August 27, 2020開発npm, svg2png

@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 のパスを指定してもよい。例えば

となるだろう。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 の処理コストを軽減するために生成済みの BrowserPage インスタンスを流用したかったので動的指定 (更新) するようにした。

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 なのとサイズの巨大さは確かに問題だが、

  1. 正常に動作している
  2. SVG から PNG を生成する処理は npm 内の依存関係のみで完結している

という理由から手を付ける気になれなかった。特に 2 は非常に重要。私は環境依存が増えることを好まない。npm として配布するなら npm エコ システムだけで環境を完結するのが第一と考える。そのため本家 svg2png の phantomjs-prebuilt 依存を支持していた。

しかし puppeteer-core の存在を知り、環境構築を npm の機能で完結可能なら乗り換えてもよいのでは?と考えるようになった。むしろ利用する Chromium を明示的に指定できるのはメリットではなかろうか。そのうえ Chromium のインストールを npm install と切り離すことで自身の npm 配布サイズも小さくできる。

というわけで puppeteer-core のお試しも兼ねて svg2png 部分を私家版として開発してみた。結果として puppeteer-core の優れた設計や便利さを実感できて非常によかった。

Chromium の機能を利用したいが Electron アプリとして開発するほどでもない、みたいな用途によさそう。開発の選択肢がひとつ増えた。

Copyright © 2009 - 2020 akabeko.me All Rights Reserved.
Designed by akabeko.me