npm icon-gen v1.0.0 release

2016年2月15日 0 開発 , , , ,

Web フロントエンド開発で npm の世界に触れ、いつか自分も作成してみたいと考えてた。

そんな折、Electron アプリ用にプラットフォームごとにアイコンを用意するのが面倒に感じていた。SVG から PNG を生成して、それを個別のツールでアイコン化する。それほど頻繁に発生する作業ではないが、ファイル形式さえ満たせればクロス プラットフォームにできそうな処理だ。これは npm の題材としてちょうどよいのでは?ということで、そういう npm を作成してみた。

せっかくなので開発の過程に得られた知見を記録しておく。今後、新たに npm 開発するとき役立つかもしれない。

もくじ

機能と設計方針

はじめに npm が実現する機能と設計方針を明確にしておく。

  • 対象とするアイコン種別は以下
  • 単一の SVG ファイルからアイコンを生成
    • デフォルト動作
    • 中間ファイルとして一時フォルダに SVG をレンダリングした PNG を生成する
    • レンダリングは svg2png で実行、内部で phantomjs を利用しているようだ
  • PNG ファイル群からアイコンを生成
    • アイコンの構成画像を自分で選びたいユーザー用
    • サイズによって図案を変えるとか、描画品質にこだわるとか、そういう人むけ
    • OS X の iconutil みたいなイメージ
    • ファイル名とサイズは固定、カラーは 32bit のみ対応
    • ICO、ICNS、Favicon に必要なものを網羅する必要あり
  • Node モジュールとして実行可能
    • module.exports するものは Promise にする
    • 実行とエラー ハンドリングが楽なので
    • コールバック形式にして Promisify はユーザー任せのほうがよい?
  • CLI サポート
    • npm-scripts だけで実行したい
    • 汎用なのでグローバルから実行したくなるかもしれない
  • コードは ES2015 で書き、リリース用ビルドで ES5 に変換する
    • Babel 利用
    • もう ES2015 以前に戻りたくない
  • プラットフォーム固有 API は使わない
    • ICO 生成とか Win32 API 使ったほうが楽だけど、がんばって Node & npm だけで実装する
  • ユニット テストを書く
    • 所定のフォーマットに基いてファイル生成する処理はユニット テストしやすいはず
    • ユニット テストの経験値を稼ぐ
  • Travis CI を利用する
    • きちんとビルドとテスト通ってますよ、という証明があるのは OSS として好ましいはず
    • GitHub で README 表示した時にバッヂあるとかっこいい、というミーハーな動機もある
  • ESDoc を利用する

プロジェクト構成

プロジェクト構成は以下のようにした。実装も含めて mysticatea/npm-run-all を参考にしている。

  /
  ├── CHANGELOG.md
  ├── LICENSE
  ├── README.md
  ├── esdoc.json
  ├── index.js
  ├── package.json
  ├── src/
  │   ├── bin/
  │   └── lib/
  └── test/

package.json の内容は以下。

{
  "name": "icon-gen",
  "description": "Icon file generator for Windows, OS X, ...etc",
  "version": "1.0.0",
  "author": "akabeko (http://akabeko.me/)",
  "license": "MIT",
  "homepage": "https://github.com/akabekobeko/npm-icon-gen#readme",
  "main": "index.js",
  "bin": "bin/main.js",
  "files": [
    "bin",
    "lib",
    "index.js"
  ],
  "keywords": [
    "Icon",
    "Generator",
    "SVG",
    "CLI"
  ],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/akabekobeko/npm-icon-gen.git"
  },
  "bugs": {
    "url": "https://github.com/akabekobeko/npm-icon-gen/issues"
  },
  "scripts": {
    "test": "mocha --timeout 50000 --compilers js:espower-babel/guess test/**/*.test.js",
    "start": "npm run watch",
    "esdoc": "esdoc -c esdoc.json",
    "build": "babel src --out-dir ./",
    "watch": "babel src --out-dir ./ --watch",
    "prepublish": "npm run build"
  },
  "dependencies": {
    "del": "^2.2.0",
    "node-uuid": "^1.4.7",
    "pngjs": "^2.2.0",
    "svg2png": "^3.0.0"
  },
  "devDependencies": {
    "babel-cli": "^6.5.1",
    "babel-preset-es2015": "^6.5.0",
    "esdoc": "^0.4.4",
    "espower-babel": "^4.0.1",
    "mocha": "^2.4.5",
    "power-assert": "^1.2.0"
  }
}

src/lib に npm のコア部分、CLI は src/bin としている。どちらも ES2015 で実装し、babel-clibabel-preset-es2015 でコンパイルされ、プロジェクト直下の lib と bin フォルダに出力される。

package.json の files フィールドは npm を公開する時に必要なファイルとフォルダを指定するホワイトリストになる。ここは最小限にしたいのでソースコードのみにしてある。コンパイル前のコードとかユニットテストを実行したくなったら Git リポジトリを直に参照してください、という方針。

Node API

icon-gen を require したときに参照されるのは index.js となる。これは参照元が ES2015 ではない可能性もあるため、旧来の Node モジュールとして実装している。つまり、

module.exports = require( './lib/main.js' );

としているだけ。Babel のコンパイル対象からも外している。lib/main.js では index.js からの CommonJS な require に備えて module.exports しておく。

import IconGenerator from './icon-generator.js';
import Logger from './logger.js';

module.exports = function( src, dest, options = { type: 'svg', report: false } ) {
  const logger = new Logger( options.report );
  switch( options.type ) {
    case 'png':
      return IconGenerator.fromPNG( src, dest, logger );

    default:
      return IconGenerator.fromSVG( src, dest, logger );
  }
};

この設計により開発は ES2015 の機能を利用しつつ、それをユーザーへ強制せずに済む。Node 本体も ES2015 対応が進みつつあるけど、公開するコードへの採用はもう一世代ぐらい後かな?と考えている。

CLI 対応

npm を CLI 対応する場合、package.json の bin フィールドにコマンドとして実行されるスクリプトを定義する。今回は bin/main.js を指定。このスクリプトは index.js と異なり、実行されるエントリー ポイント関数を含むことになる。

処理としては自己完結しているため、この部分は ES2015 で書いてコンパイル対象としている。CLI として実行されるときは、コンパイルされたコードがそのまま呼び出されるだけなので、Node API としての require を想定しなくてよい。

このあたりの実装は npm-run-all をほぼ踏襲している。npm に渡されるコマンドライン引数は process.argv に格納され、先頭には常に node が入る。そのため npm 固有の引数は process.argv.slice( 2 ) で抽出することになる。

引数のうち -h--help-v--version は特別で、slice した argv の先頭だけ評価する。これらはヘルプとバージョン情報の出力なので通常実行では無視することにした。この辺も npm-run-all そのまま。

function main( args, stdout ) {
  switch( args[ 0 ] ) {
    case undefined:
    case '-h':
    case '--help':
      return showHelp( stdout );

    case '-v':
    case '--version':
      return showVersion( stdout );

    default:
      return execute( args );
  }
}

残りの引数は以下のように解析している。引数とデータが組になっているものは、引数の次の要素も評価する。その結果を Object 化して返す。デフォルト値もここで吸収。

function parseArgs( args ) {
  const options = {};

  args.forEach( ( arg, index ) => {
    switch( arg ) {
      case '-i':
      case '--input':
        if( index + 1 < args.length ) {
          options.input = Path.resolve( args[ index + 1 ] );
        }
        break;

      case '-o':
      case '--output':
        if( index + 1 < args.length ) {
          options.output = Path.resolve( args[ index + 1 ] );
        }
        break;

      case '-t':
      case '--type':
        if( index + 1 < args.length ) {
          options.type = args[ index + 1 ];
        }
        break;

      case '-r':
      case '--report':
        options.report = true;
        break;

      default:
        break;
    }
  } );

  if( !( options.type ) || ( options.type !== 'svg' && options.type !== 'png' ) ) {
    options.type = 'svg';
  }

  return options;
}

コマンドライン引数さえ解析できたら、後はその内容にあわせて lib 側にあるコア部分を呼び出すだけ。この辺の処理は argv に任せたほうがよいのかもしれない。今回は初 npm なので勉強のため自前で実装したが、次はそうする予定。

npm link

CLI のテストを実行する際 npm link コマンドが便利だった。これを実行すると現在のプロジェクトに対するシンボリックリンクをグローバルに貼ってくれる。つまり CLI を npm install -g したような状態が構築される。ビルドされるのは package.json の scripts で prepublish を指定しているからだろうか。npm link するたびにビルドが走る。

リンクを削除するときは npm unlink を実行すればよい。

ユニット テスト

ユニットテストは前に ES6 コードをテストするで書いた mocha + power-assert + espower-babel を採用。Web フロントエンドや Electron アプリ開発でも同じ構成にしている。テスト自体も ES2015 で書けるところがよい。

テストの特記事項としては、mocha の Promise 対応が挙げられる。詳しくは MochaがPromisesのテストをサポートしました | Web Scratch を参照のこと。現在の mocha だと Promise を return することで、非同期処理の終了で done を呼ばなくても済む。catch が発生した時はテスト自体の失敗として扱われる。

describe( 'IcnsGenerator', () => {
  const testDir = Path.resolve( './test' );
  const dataDir = Path.join( testDir, 'data' );

  it( 'generate', () => {
    const images = IcnsConstants.imageSizes.map( ( size ) => {
      const path = Path.join( dataDir, size + '.png' );
      return { size: size, path: path };
    } );

    return IcnsGenerator
    .generate( images, Path.join( testDir, 'sample.icns' ), new Logger() )
    .then( ( result ) => {
      assert( result );
      Fs.unlinkSync( result );
    } );
  } );
} );

これを前提として、アイコン生成メソッドは Promise を返すようにした。Promise だとコールバックのインターフェースが統一される点も気に入っている。

そういえば、CLI 部分へのユニット テストは未実装だった。npm link で手動テストしていたが、インターフェースとなる部分はコマンドライン引数のみなので、その解析部分はテスト可能な気がする。次のバージョンで対応したい。

Travis CI

GitHub に公開されているプロジェクトの README で、よく build passing みたいなバッジが貼られている。あれは Travis CI などの Web サービスと連携することで表示できるのだが、このプロジェクトでも対応してみた。

Web に散見される Travis CI の紹介記事をみると、けっこう機能や UI が変遷しているようだ。現在は GitHub アカウントで Travis CI にログインするとリポジトリを認識してくれる。このログインも、先に GitHub 側で実行しておくと Travis CI ではサイト右上の Sign in with GitHub ボタンを押すだけで済む。

プロジェクト一覧から連携したいものを選ぶと、GitHub 側の Token 発行なども自動的におこなわれる。すごく楽だ。Travis CI 側のリポジトリ設定は以下のようにした。

設定 説明
Build only if .travis.yml is present ON .travis.yml のあるプロジェクトだけを対象とする。
Limit concurrent jobs OFF 並列実行する job の上限。
Build pushes ON push 時に job を実行する。
Build pull requests ON pull request 時に job を実行する。

私は Git リポジトリのブランチを master/develop で切っている。develop で開発してリリース可能になったら master へ merge する運用なのだが、この場合、いちどもリリースしていない状態だと develop にしか .travis.yml が存在しないため、master ブランチに対する job の実行は無駄になる。そのため Build only… を ON にしている。

その他はデフォルトのまま。Limit concurrent jobs はブランチや push が増えた時に有効なのだろうか?特に困っていないので OFF でよいか。

プロジェクト側の設定はルートに配置した .travis.yml というファイルに記述する。

sudo: false
language: node_js
before_script:
 - npm run build
node_js:
  - "4"
  - "5"

設定値については Building a Node.js project – Travis CI を参照のこと。今回は以下のようにした。

設定 説明
sudo false job に sudo 経由で実行したいコマンドがあるなら true。今回は不要
language node_js 言語指定。今回は Node。
before_script npm run build job の前に実行するスクリプト。ES2015 コードのコンパイルが必要なので、npm-scripts のそれを指定。
node_js 4, 5 job を実行する環境。Node v4 と v5 系を指定。

これで push や pull request があった時に job が実行される。

さて、ここでお待ちかねのバッジである。Travis CI のリポジトリ画面にゆくと上部にバッジが貼られている。これをクリックするとダイアログが表示され、その中に URL が記載されている。これが README などに貼り付ける画像になる。ダイアログ上で BRANCH やその下の書式を切り替えると URL も更新される。

今回は README.md に貼るので書式は MARKDOWN、BRANCH は安定版の master とした。

この種のバッジについては クラウドサービスを活用して README にバッジをペタペタ貼る – Qiita が参考になる。そういえば esdoc/README.md には document カバレッジを示すバッジがあるけど、これは Hosting Service を利用することで生成できるのだろうか?

ESDoc

これまでプロジェクトのローカルで ESDoc を利用してきたが、今回のプロジェクトは npm なので API 仕様書として ESDoc Hosting Service に登録してみる。

はじめにプロジェクト自体の ESDoc 設定が必要。これはいつもどおりルートに esdoc.json を用意すればよい。

{
  "source": "./src",
  "destination": "./esdoc",
  "test": {
    "type": "mocha",
    "source": "./test"
  }
}

次に ESDoc Hosting Service への登録。これは対象プロジェクトが GitHub へ公開されている必要あり。URL 入力欄の説明には `require git@github.com:foo/bar.git style URLP とある。ただし前半の git@github.com: は固定で foo/bar 部分を akabekobeko/npm-icon-gen のように user/repository にするようだ。前半が固定なら後半だけ入力させるほうがよいと思う。もしかすると前半が変更されることもあるのかもしれない。例えば将来 Bitbucket もサポートするとか。

入力画面にはブランチ選択がない。そのため、おそらく master が対象になるのだろう。よって私のように master 以外に開発ブランチを持っている場合は、初回公開の前に master へ merge しておく必要があるのかもしれない。この辺、画面や FAQ にも説明が見当たらなかったので予想で書いてる。

これらの設定を経て、登録に成功すると API Document のように ESDoc の出力結果がホスティングされる。簡単な API 説明は README に書き、詳細はこちらという感じでリンクを貼る運用がよさそう。後で README をそのように修正する予定。

SVG to PNG

SVG から PNG への変換は svg2png でおこなう。内部で phantomjs が利用されているため、描画品質は WebKit 相当だろうか。

icon-gen では 16×16 〜 1024×1024 まで計 17 種の PNG を生成しているのだが、この処理にはかなり時間が掛かる。画像ごとに phantomjs を初期化しているのが問題なのかもしれない。SVG を PNG 化するだけのエンジンがあれば変更したいけど今のところ見つかっていない。処理時間の長さは、そういうものだと受け入れることにした。

念のため、描画品質や処理時間を問題とするユーザー用に PNG からアイコンを生成するモードも実装している。

ICO ファイル生成

icon-gen の実装で最も苦戦したのが ICO ファイル生成である。ファイル ヘッダーとアイコンのディレクトリ生成までは簡単だけど、画像部分が BMP になっていて、これを作成するのが非常に面倒。

まず fs.readFile とかで PNG ファイルを読み込んでも、そのままでは埋め込めない。BITMAPINFOHEADER を生成したうえで、画像データは PNG をデコードして BMP に変換しなければならない。

当初、この処理が難しければ ImageMagick の npm wrapper へ逃げるつもりだった。しかしそれらは別途 ImageMagick をインストールする必要があるため、構成を npm で完結できない。また、これを利用せず Node & npm だけで ICO 生成している実装は見つからなかったので、これを実現できれば先例となれる!という欲もあり、がんばることにした。

BITMAPINFOHEADER はよいとして、PNG のデコードを実装するのは面倒なので pngjs を利用することにした。PNG を操作する npm の中で最も機能が豊富、かつ最近までコミットがあるというのが決め手になった。あと、Sync API があるのは嬉しい。PNG.sync.read に fs.readFileSync などで読み込んだ Buffer を渡すと同期的に解析してくれる。

解析結果にはサイズや色などのメタデータとデコードされた RGBA 画像 Buffer があるので、これらを使って BITMAPINFOHEADER と BMP 変換を実装できる。以下はその抜粋。

export default class IcoGenerator {
  static createBitmapInfoHeader( png, compression ) {
    const b = new Buffer( IcoConstants.BitmapInfoHeaderSize );
    b.writeUInt32LE( IcoConstants.BitmapInfoHeaderSize, 0 ); // 4 DWORD biSize
    b.writeInt32LE( png.width, 4 );                          // 4 LONG  biWidth
    b.writeInt32LE( png.height * 2, 8 );                     // 4 LONG  biHeight
    b.writeUInt16LE( 1, 12 );                                // 2 WORD  biPlanes
    b.writeUInt16LE( png.bpp * 8, 14 );                      // 2 WORD  biBitCount
    b.writeUInt32LE( compression, 16 );                      // 4 DWORD biCompression
    b.writeUInt32LE( png.data.length, 20 );                  // 4 DWORD biSizeImage
    b.writeInt32LE( 0, 24 );                                 // 4 LONG  biXPelsPerMeter
    b.writeInt32LE( 0, 28 );                                 // 4 LONG  biYPelsPerMeter
    b.writeUInt32LE( 0, 32  );                               // 4 DWORD biClrUsed
    b.writeUInt32LE( 0, 36 );                                // 4 DWORD biClrImportant

    return b;
  }

  static convertPNGtoDIB( src, width, height, bpp ) {
    const cols   = ( width * bpp );
    const rows   = ( height * cols );
    const rowEnd = ( rows - cols );
    const dest   = new Buffer( src.length );

    for( let row = 0; row < rows; row += cols ) {
      for( let col = 0; col < cols; col += bpp ) {
        // RGBA: Top/Left -> Bottom/Right
        let pos = row + col;
        const r = src.readUInt8( pos );
        const g = src.readUInt8( pos + 1 );
        const b = src.readUInt8( pos + 2 );
        const a = src.readUInt8( pos + 3 );

        // BGRA: Right/Left -> Top/Right
        pos = ( rowEnd - row ) + col;
        dest.writeUInt8( b, pos );
        dest.writeUInt8( g, pos + 1 );
        dest.writeUInt8( r, pos + 2 );
        dest.writeUInt8( a, pos + 3 );
      }
    }

    return dest;
  }
}

PNG は RGBA で左上が原点。一方、BMP は BGRA で左下が原点である。よって RGBA の Buffer を走査しながら色と座標の変換が必要になる。RGBA 決め打ちなのだから bpp は固定で 4 にしたほうよかったかも。それと、せっかく PNG を利用しているのにデコードされたものをそのまま BMP 化しているので無圧縮になっている。ICO フォーマットとしては妥当だし、現代の間隔では気になるほどのサイズではないが、圧縮について理解できたなら改善したい処理である。

もうひとつ重要なことがあった。Node でファイル書き込みを実装する方法として Buffer と Stream がある。Buffer は固定サイズなかわりに offset 値を指定して任意の位置へ書き込める。Stream は可変サイズだが、追記しかできないっぽい。そのため ICO のように画像のメタデータと実体が離れている構造を処理するとき seek が使えなくて困った。

pngjs はメタデータと実体が一緒になっているので、

  1. PNG を読み込む
  2. メターデータ領域の一部を更新
  3. 画像領域の一部を更新

という風にファイル単位の順次処理にしてメモリを節約したかったのだけど、seek できないので、

  1. 全 PNG を一括で読み込む
  2. メタデータ領域を一括で追記
  3. 画像領域を一括で更新

となってしまった。可変長のデータ、それも大きめなものを扱う場合はカレントを最小にするのが定石であり、この対応は不本意である。ただ、これは Stream に対する理解不足によるもので改善可能な気がする。

npm publish した後に気づいたのだけど、生成した ICO ファイルを @icon変換 で開くと「ストリームからの読み込みエラー」が発生する。OS X の Finder とプレビュー、Windows の Explorer や Photo アプリでは正常表示できているのだが、まだ不完全なのかもしれない。継続調査する。

ICNS ファイル生成

ICO に比べて ICNS は構造が単純で扱いやすい。

  • ファイル ヘッダー
    • アイコン情報 ( ヘッダー + PNG 画像 ) x 画像数

ヘッダーを書き込んだ後は、画像の数だけアイコン情報を追記してゆくだけで済む。アイコン情報の画像部分も PNG ファイルの内容をそのまま書き込めばよく、変換は不要である。ICO 生成で詰まったとき、気晴らしに ICNS へ着手したらトントン拍子に実装できて、やる気の維持に役立った。こちらも難しかったら開発を断念していたかもしれない。

Favicon ファイル生成

Favicon は audreyr/favicon-cheat-sheet) の内容にそった PNG ファイルと favicon.ico を生成。PNG はコピー、ICO は前述の処理を流用するだけでよい。

Web サイト用の Favicon PNG は単にサイズ分のファイルを用意するだけだが、チートシートにまとめられる程度に普及しているなら、それを一括生成するツールがあってもよいと考え、機能として組み込んだ。アイコンを生成するという意味で、可能な限り多くのフォーマットをサポートしたい。そのうえで必要なものだけ出力するのがベストだろう。

課題

将来の課題をまとめる。

  • ICO をより完全なフォーマットにする
    • @icon変換 のエラーを修正する
    • PNG を PNG のまま、あるいは BMP も圧縮して書き込む
  • 出力モードの実装
    • ICO、ICNS、Favicon を個別出力できるようにする
    • CLI の場合 -m --mode オプションを提供
    • デフォルトは allico,icns,favicon のように必要なものを列挙するのを想定
    • -tpng の場合、実際に使用される PNG だけ用意すればよい仕様とする

その他、この記事を読まれた方で要望やバグ報告などがあれば Issues まで気軽にどうぞ。


REPLY

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です