npm icon-gen v1.2.0 release

2017年11月21日 0 開発 , ,

icon-gen v1.2.0 をリリースした。

今回の目玉は ICNS における is32il32 のサポート。Wikipedia の Apple Icon Image format によると ICNS は現行の macOS なら ic07ic14 があれば十分にみえる。

しかし GitHub にて Mac OS X finder uses also is32 and il32 icns. という Pull Request があった。どうやら is32il32 も必要とのこと。これらがないと Finder のリスト表示でアイコンが消えるらしい。

この報告をうけて High Sierra 環境でリスト表示を試したものの、正常に表示されていたので古い macOS 固有の問題かもしれない。私の環境だと再現できないので Pull Request をそのまま採用しようかな?と実装を確認したところ is32il32 の画像部分が PNG ベタ書きになっていた。

本来、これらの画像部分は特殊な圧縮がかかっている。以前、サポートしようとしときに見つけた以下のページによれば PackBits 形式らしい。また RGBA のうち R、G、B をチャンネル単位で圧縮し、A は s8mkl8mk という対となるマスクとして別ブロックに書き込むのだという。

このときは面倒そうだし、私の環境では問題おきてないし、なにより将来なくなるであろうレガシーな形式であることからサポートを見送った。しかし Pull Request がきたのを契機にあらためて PackBits 圧縮を検討してみることにした。

PackBits

PackBits は Run Length Encoding (以下、RLE) の一種である。

アルゴリズムとしては非常に単純なため様々な言語で実装されている。npm だと packbits が見つかった。しかしこれは String が対象。icon-gen 的には Buffer と Array、つまりバイナリーとして扱いたいので別の実装をあたることにした。結果、

が癖もなく分かりやすかったので Node に移植してみた。

PackBits は Apple がまだ Apple Computer だった時代に圧縮と展開のサンプル データを公開しており Wikipedia にもそれが掲載されている。元コードにはテストがなかったので、このサンプルを基準としたテストを実装し、正常に動作することが確認できた。

ではさっそく ICNS へ!というわけで動かしてみたところ、出力されたアイコンを macOS の Preview で確認すると色がめちゃくちゃだ。Alpha にあたる s8mkl8mk は無圧縮なので、これにあたる透過だけが正常に描画される状態。

PackBits としてはサンプルの圧縮と展開に成功、つまり正常と思われるのだが。…もしかして PackBits 圧縮ではない?

ICNS 専用 RLE

改めて Wikipedia の ICNS ページを読みなおしたら

Over time the format has been improved and there is support for compression of some parts of the pixel data. The 32-bit (“is32”, “il32”, “ih32″,”it32”) pixel data are often compressed (per channel) with a format similar to PackBits.[1]

PackBits ではなく似て非なるものらしい。出典としてあげられてる資料も確認。

あらら、やはり PackBits ではないのか。RLE の一種であることは確かだが Apple による公式な仕様はなくて Peter Stuer 氏がリバース エンジニアリングしたのだという。G. Brannon Smith による Java 実装もあるらしい。

とりあえず今後の開発にそなえて is32il32 の実データがほしい。しかし High Sierra に付属している iconutil でアイコン生成するとこれらは存在せず、かわりに ic04ic06 が埋め込まれている。

じゃあこれらを書けばいいのでは?とバイナリー エディタで調べてみたら画像部分の先頭に ARGB とあり、PNG とも RLE とも異なるようだ。これについては別途調査するとして、既存アプリのアイコンならどうかと Firefox.app から持ってきたものを調べたら is32il32 が存在した。

テスト用に ICNS からブロックのヘッダーとボディを抽出するメソッドを実装し、

export default class ICNSGenerator {
  /**
   * Unpack an icon block files from ICNS file (For debug).
   *
   * @param {String} src  Path of the ICNS file.
   * @param {String} dest Path of directory to output icon block files.
   *
   * @return {Promise} Promise object.
   */
  static _debugUnpackIconBlocks (src, dest) {
    return new Promise((resolve, reject) => {
      Fs.readFile(src, (err, data) => {
        if (err) {
          return reject(err)
        }

        for (let pos = HEADER_SIZE, max = data.length; pos < max;) {
          const header = data.slice(pos, pos + HEADER_SIZE)
          const id     = header.toString('ascii', 0, 4)
          const size   = header.readUInt32BE(4) - HEADER_SIZE

          pos += HEADER_SIZE
          const body  = data.slice(pos, pos + size)
          Fs.writeFileSync(Path.join(dest, id + '.header'), header, 'binary')
          Fs.writeFileSync(Path.join(dest, id + '.body'), body, 'binary')

          pos += size
        }

        resolve()
      })
    })
  }
}

Firefox から当該ブロックを取得。また il32 に相当する 32×32 の PNG を得るため ic11 のボディを PNG として保存。icon-gen は SVG だけでなく PNG からも ICNS を生成できるので、これを使用して Firedox の il32 と一致するバイナリーを生成できれば OK というわけだ。

参考資料にある libicns の実装を眺めてみる。

ソース コードとしては icns_rle24.c
icns_encode_rle24_data が ICNS の RLE 処理。ありがたいことに ImageMagick といった外部ライブラリーには依存せず、自己完結している。バイト配列の操作と標準関数のみで実装されているため Node にも移植しやすそうだ。

というわけで移植版を動作させたところ、出力されたバイナリーは見事にサンプルと一致した。ICNS ファイルを Preview で開いても正常に描画されている。

しかし libicns のライセンスは GPL である。icon-gen は既に MIT ライセンスで公開しており Dependents も 4。それらは MIT か Apache-2.0 なので icon-gen が libicns 移植を採用すると Copyleft により Dependents にも影響がある。

ならばスクラッチか。既に移植しているため完全なクリーン ルーム開発はできないが
icns_encode_rle24_data にまとまった仕様がコメントされている。

// Assumptions of what icns rle data is all about:
// A) Each channel is encoded indepenent of the next.
// B) An encoded channel looks like this:
//    0xRL 0xCV 0xCV 0xRL 0xCV - RL is run-length and CV is color value.
// C) There are two types of runs
//    1) Run of same value - high bit of RL is set
//    2) Run of differing values - high bit of RL is NOT set
// D) 0xRL also has two ranges
//    1) for set high bit RL, 3 to 130
//    2) for clr high bit RL, 1 to 128
// E) 0xRL byte is therefore set as follows:
//    1) for same values, RL = RL - 1
//    2) different values, RL = RL + 125
//    3) both methods will automatically set the high bit appropriately
// F) 0xCV byte are set accordingly
//    1) for differing values, run of all differing values
//    2) for same values, only one byte of that values
// Estimations put the absolute worst case scenario as the
// final compressed data being slightly LARGER. So we need to be
// careful about allocating memory. (Did I miss something?)
// tests seem to indicate it will never be larger than the original

雑に翻訳。原文と訳は libicns にならって GPL とする。

ICNS RLE データの仮定 :
A) 各チャンネルは次のチャンネルから独立してエンコードされる
B) エンコードされたチャンネルは以下のようになります
   0xRL 0xCV 0xCV 0xRL 0xCV - RL は Run Length で CV はカラー値です
C) 2 種類の実行 (Run)
   1) 同じ値の実行 - RL の上位ビットを設定します
   2) 異なる値の実行 - RL の上位ビットは設定されません
D) 0xRL には 2 つの範囲もあります
   1) 上位ビットの設定された RL は 3 〜 130
   2) 上位ビットの設定されない RL は 1 〜 128
      ※原文の "clr" は文脈的に clear の略 = 「設定されない」と解釈
E) 0xRL バイトは以下のように設定されます :
   1) 同じ値は RL = RL - 1
   2) 異なる値は RL = RL + 125
   3) 両方のメソッドは自動かつ適切に上位ビットを設定します
F) 0xCV はそれ (0xRL) に応じて設定されます
   1) 異なる値の場合は、すべての異なる値の実行
   2) 同じ値なら、その値を 1 バイトだけ

最終的な圧縮データはわずかに大きいため、最悪な場合のシナリオで見積もります。そのため私たちはメモリ割り当てについて注意する必要があります。 (なにか私は見落としていますか?)
テストではそれが元よりも大きくなることはないと考えられます。

これを見ながら途中まで実装したのだが、そういえばこの特殊な RLE がリバース エンジニアリングによって解明されたこと、Java 実装があるらしいことを思い出す。ならばそれもチェックしておこうかと探してみたら以下をみつけた。

ImageJ という Java の画像処理ライブラリーの一部らしい。ライセンスはコード冒頭をみるに修正 BDS。もしこれを移植して動くようなら採用しよう。

  • libicns 移植版で生成されたバイト配列をテストの基準とする
  • ImageJ 移植版で生成されたバイト配列がテストを満たすようにする

という条件のもとに移植。結果、テストをパスして icon-gen が出力したアイコンも正常に書き込まれていた。icon-gen v1.2.0 としてはこの実装を採用。

オマケとして libicns 移植版の圧縮処理を Gist へ公開。ライセンスはオリジナルにもとづき GPL。

iconutil の出力形式

macOS 付属の CLI ツール、iconutil により ICNS ファイルを生成できる。使用方法と必要なファイルについては以下の記事がわかりやすい。

さて、前述のように High Sierra と未満で iconutil が出力したファイル内の構成が異なる。icon-gen としては High Sierra 未満の ICNS を出力しているわけだが、現行のものへ正式対応する際の参考に調査した形式をまとめておく。

旧は Sierra、新は High Sierra の iconutil で出力したもの。一方にしかないものは他方を空欄してある。画像サイズは正方形だが、そうであることを明示するため WxH 形式で記述しておく。

サイズ 内容
is32 16×16 R、G、B を独立して特殊 RLE 圧縮。RRR、GGG、BBB と並ぶ。
s8mk 16×16 is32 の透過部分。RGBA の A を無圧縮。
il32 32×32 R、G、B を独立して特殊 RLE 圧縮。RRR、GGG、BBB と並ぶ。
l8mk 32×32 il32 の透過部分。RGBA の A を無圧縮。
ic04 16×16 32bit ARGB。画像形式は謎。
ic05 32×32
ic06 48×48
ic07 ic07 128×128 32bit ARGB。画像形式は PNG ファイル。
ic08 ic08 256×256
ic09 ic09 512×512
ic10 ic10 1024×1024
ic11 ic11 32×32
ic12 ic12 64×64
ic13 ic13 256×256
ic14 ic14 512×512
info info バイナリー形式の plist。plutil で XML 化すると iconutil のバージョン情報などが定義されていることを確認できる。

icon-gen は旧形に準拠するが info は書き出していない。plist バイナリーを埋め込んでもよいけれど、これなしにも有効な ICNS と見なされるようなので無視している。

Issue #54 で調査した際は Safari v10.1 (12603.1.30.0.34) が旧式で Firefox 54 は旧式から ic07ic11ic14 が抜けた状態だった。

まとめ

長らく懸念だった is32il32 処理が解決されてうれしい。

ところで今回は C# (PackBits)、C 言語と Java (特殊 RLE) を移植してみたのだが、Node の標準 API と JavaScript の Array が高機能なおかげで移植しやすかった。開発プラットフォームとして必要十分な機能はあるので OS やハードウェア依存の API (Win32 とか DirectX など) を使用していなければ、Node は移植先として有望と感じた。

しかし今回は局所的な移植だったからよかったものの、大規模なものはどうなのだろう。ある時点の移植はできても本家の変更に追従し続けるのは極めて難しそう。sqlite3 は移植ではなく node-gyp による wrapper となっているのは、この事情を踏まえてのことだろう。

などと考えていたらタイムリーな記事がはてブのホット エントリーにあがっていた。

C 言語の実装を kripken/emscripten で WASM にビルドして npm 配布したのだという。これは面白い。

Node は v8.0.0 から、Chrome も v58 から WASM に対応している。Electron でいうと v1.7.0 で Chrome v58、v1.8.0 から Node v8.2.1 を採用しているため、Electron v1.8 系なら Main/Renderer プロセスのどちらでも WASM を使用できるはず。

つまり豊富な C 言語の資産を流用しやすくなるのだ。すばらしい。

WASM は機能の縛りが厳しいため移植を完全に自動化するのは難しいだろうけど、そのへんは emscripten のような周辺ツールが解決すると予想している。例えば File I/O などが含まれていたら WASM とその wrapper で分担・抽象化するとか。

例えば前述の SQLite が WASM 化されたなら Electron アプリに組み込みやすくなるだろう。WASM はパフォーマンスよりもこのような移植方面で期待している。

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 まで気軽にどうぞ。