Macintosh OSX用アイコンセットをWindows上で作ってしまう方法へ至る道。OSXの.icnsファイルフォーマットについて解説する。
動機
艦板-KanPan- を開発するとき、最初からMacintoshとWindows両方に対応すると決めていたが、その際両者でフォーマットが異なるアイコンリソースをどうするかという問題があった。OSXでは.icns、Windowsでは.icoというファイル形式が使われる。それぞれを他方のプラットフォーム上のコマンドラインから作成できれば、make作業を一方の上で完結できる。とりあえず資料が豊富な.icoは放っておいて.icnsについて調べてみたところ意外と簡単な方法で作成できることが分かった。
iconutil ユーティリティ
OSX添付の純正アイコンリソース作成ユーティリティはiconutil
という。このCLIコマンドは以下のように使用する。
- 以下の10個のフルカラーPNG画像(RGBA)を格納した
foo.iconset
というフォルダを作成する。アイコンファイル名は厳密にこの通りであり、フォルダ名の.iconsetという拡張子もこの通りでなければならない。
|ファイル名|画像サイズ|
|:-|:-:|
|icon_16x16.png|16x16|
|icon_16x16@2x.png|32x32|
|icon_32x32.png|32x32|
|icon_32x32@2x.png|64x64|
|icon_128x128.png|128x128|
|icon_128x128@2x.png|256x256|
|icon_256x256.png|256x256|
|icon_256x256@2x.png|512x512|
|icon_512x512.png|512x512|
|icon_512x512@2x.png|1024x1024|
- ターミナルで以下のように実行するとカレントディレクトリに
foo.icns
というアイコンファイルが作成される。
iconutil -c icns foo.iconset
10個のファイル(画像サイズ自体は7種類)を用意しろというのは御無体だが、これはまあ原画をSVG形式で作成して所定サイズとファイル名のPNG画像にコンバートすればなんとかなる。ともかく出来上がった.icnsファイルを調べてみると次のようになっていた。
ICNSファイルフォーマット
ICNSファイルは次のような構造になっている
|:-:|
|ICNSファイルヘッダ|
|アイコンヘッダ|
|アイコンデータ|
|アイコンヘッダ|
|アイコンデータ|
|:|
まずファイル先頭にICNSファイルヘッダがあり、その後ろにアイコンヘッダとアイコンデータの組(ブロック)が任意個数続く。
ICNSファイルヘッダ
ICNSファイルヘッダの大きさは8バイトで、以下の2フィールドを持つ。
|Offset|Length|内容|
|-:|-:|:-|
|0|4|ファイル識別子`ICNS` (リテラルバイト文字列 0x69 0x63 0x6e 0x73)|
|4|4|ICNSファイル全体のバイナリサイズ (MSB First/Big Endian)|
先頭の4バイトがファイル識別子なのはMachintosh系データフォークの伝統だ。
アイコンヘッダ
アイコンヘッダの大きさは8バイトで、以下の2フィールドを持つ。
|Offset|Length|内容|
|-:|-:|:-|
|0|4|アイコンタイプ識別子(リテラルバイト文字列)|
|4|4|ブロック長(アイコンヘッダ+アイコンデータの合計バイト長) (MSB First/Big Endian)|
アイコンタイプ識別子もまた4バイトのリテラル文字列で、これがアイコンデータの画像表示サイズと画像フォーマットとを示している。続くフィールドは後続するアイコンデータのバイナリサイズ+8に一致する。
アイコンデータ
これのデータフォーマットはアイコンタイプ識別子によって規定されるが、iconutilが作成するファイルの場合はPNG画像ファイルデータそのままかRGB生データかマスクデータである。とにかくPNG形式のアイコン識別子が付いていたならPNGファイルがそのまま埋め込まれているので、何も難しいことはない。
アイコンタイプ識別子一覧
iconutilが作成するアイコンファイルに含まれるアイコン識別子の一覧を次に示す。なお識別子のレターケースは区別される。
|Type|Length|Size|MacOS|詳細|
|:-:|:-:|:-:|:-:|:-|
|ic07|可変|128x128|10.7+|128x128 PNG/JPEG2000|
|ic08|可変|256x256|10.5+|256x256 PNG/JPEG2000|
|ic09|可変|512x512|10.5+|512x512 PNG/JPEG2000|
|ic10|可変|1024x1024|10.7+|1024x1024 PNG/JPEG2000 (1024x1024, 512x512@2x)|
|ic11|可変|32x32|10.8+|32x32 PNG/JPEG2000 (16x16@2x)|
|ic12|可変|64x64|10.8+|64x64 PNG/JPEG2000 (32x32@2x)|
|ic13|可変|256x256|10.8+|256x256 PNG/JPEG2000 (128x128@2x)|
|ic14|可変|512x512|10.8+|512x512 PNG/JPEG2000 (256x256@2x)|
|il32|可変|32x32|8.5+|32x32 24bit TrueColor RGB|
|is32|可変|16x16|8.5+|16x16 24bit TrueColor RGB|
|l8mk|1,024|32x32|8.5+|32x32 8bit MASK|
|s8mk|256|16x16|8.5+|16x16 8bit MASK|
ic11からic14はRetinaディスプレイ用に追加されたもので、それぞれその半分の画像サイズのアイコンと対応している。ic10はRetina対応であると同時にOSX10.7以降のデフォルトアイコンサイズである。極端な話、このic10さえあれば、OSX10.7以降のファインダー&ドック表示はすべて可能になる。
一方で以下のアイコンタイプも10.7で登場したのだが iconutil では生成されないようだ。代わりに非Retina用等倍の16x16と32x32についてはOSX10.7のものではなくMaxOS8.5のアイコンフォーマットにコンバートして収録されている。これは後方互換性を維持するためだろう。
|Type|Length|Size|MacOS|詳細|
|:-:|:-:|:-:|:-:|:-|
|icp4|可変|16x16|10.7+|16x16 PNG/JPEG2000|
|icp5|可変|32x32|10.7+|32x32 PNG/JPEG2000|
|icp6|可変|64x64|10.7+|64x64 PNG/JPEG2000|
MacOS8.5形式アイコンフォーマット
il32とl8mk、is32とs8mkはそれぞれアイコンカラーデータとアイコンマスクデータのペアとして使用される。常にペアであるため個々に単独の画像ファイルとしては現れず、少なくとも2ブロックから成る.icnsファイルとして存在する。
アイコンカラーデータは1画素あたりRGB24bitだが、RGBそれぞれ別の(8bpp)プレーン毎に、連続する左上原点ビットマップデータ列をPackBitsしてまとめられている。
|Offset|Length|詳細|
|-:|-:|:-|
|0|4|アイコンタイプ識別子`il32` (0x69 0x6c 0x33 0x32)|
|4|4|ブロック長 (MSB First)|
|8|可変|Rプレーン1,024バイトのPackBitsデータ|
|?|可変|Gプレーン1,024バイトのPackBitsデータ|
|?|可変|Bプレーン1,024バイトのPackBitsデータ|
|Offset|Length|詳細|
|-:|-:|:-|
|0|4|アイコンタイプ識別子`is32` (0x69 0x73 0x33 0x32)|
|4|4|ブロック長 (MSB First)|
|8|可変|Rプレーン256バイトのPackBitsデータ|
|?|可変|Gプレーン256バイトのPackBitsデータ|
|?|可変|Bプレーン256バイトのPackBitsデータ|
アイコンマスクデータも同様に1画素あたり8bit(8bpp)の連続する左上原点ビットマップデータ列だが、無圧縮のベタデータとして定義されている。
これはPNG画像フォーマットのアルファチャンネルと実質同じもので、アイコン画像の背景デスクトップからの切り抜きとアンチエイリアス表現を実現する。 なお画像縦横サイズについてはアイコンタイプ識別子で一意に決定されるためデータブロック内には現れない。
|Offset|Length|詳細|
|-:|-:|:-|
|0|4|アイコンタイプ識別子`l8mk` (0x6c 0x38 0x6d 0x6b)|
|4|4|ブロック長 (MSB First)|
|8|1,024|MASKプレーンの8bppビットマップデータ|
|Offset|Length|詳細|
|-:|-:|:-|
|0|4|アイコンタイプ識別子`s8mk` (0x73 0x38 0x6d 0x6b)|
|4|4|ブロック長 (MSB First)|
|8|256|MASKプレーンの8bppビットマップデータ|
PackBits連長圧縮アルゴリズム
カラープレーンのビットマップは生データではなく、PackBitsと呼ばれる連長圧縮アルゴリズムでコンバートされている。これは古き良きMacintosh時代の、PICT (QuickDraw PICTure)ファイルフォーマットで用いられていたものだからとうぜん当時の資料を調べれば詳細を知ることが出来る。そのPackBitsデータ列は次のように解釈すれば元のビットマップデータへ復元・展開できる。
- ポインタ上の1バイトが128未満(MSBが0)であるならこのバイトが示す0〜127に1を加え、次のそのバイト数(1〜128バイト)を取り出してそのまま出力し、ポインタを進める。
- ポインタ上の1バイトが128以上 (MSBが1)であるならこのバイトが示す128〜255から125を引き、取り出した次の1バイトをその回数(3〜130回)繰り返して出力し、ポインタを進める。
このPackBitsの圧縮アルゴリズムを真面目に実装しようとするとそれなりに面倒だが抜け道はある。無圧縮でよしと妥協すれば、128バイト毎にバイトストリームを切り刻み、0x7Fを前置した129バイトブロックに整えて横流しすればすむのである。32x32画素プレーンと16x16画素プレーンのいずれも128バイトの整数倍バイト長なのだから全く不都合はない。32x32画素プレーンなら計8ブロック1,032バイト、16x16画素プレーンなら計2ブロック258バイトの固定長データとして処理してしまえる。
unosxicns.pl
ここまで解ってしまえばあとはもう悩むことはない。unosxicns.pl
は以上の情報をまとめて .icnsファイルから .png1ファイルを抽出するように記述したものだ。ActivePerl(5.10+)2と ImageMagick(PerlMagick)3で動作する。OSX他でも MacPortsで同等の環境を揃えれば動作する。
使い方は引数に .icnsファイルを指定すると同名の .iconsetディレクトリを作成してその中に抽出したicon_NNNxNNN.png
を生成、あるいは-t
オプションを付加した場合はアイコンタイプ.png
を生成する。前掲のアイコンタイプ以外は対応外として無視する。
unosxicns.pl -t foo.icns
ls foo.iconset
ic07.png ic08.png ic09.png
ic10.png ic11.png ic12.png
ic13.png ic14.png il32.png
is32.png
#!/usr/bin/perl
# $Id: unosxicns.pl 122 2015-03-16 04:06:57Z askn $ UTF8
use 5.010;
use strict;
use warnings;
use File::Basename;
use Image::Magick;
use Getopt::Std;
my %iconset = (
ic07 => [128, 1],
ic08 => [256, 1],
ic09 => [512, 1],
ic10 => [1024, 2],
ic11 => [32, 2],
ic12 => [64, 2],
ic13 => [256, 2],
ic14 => [512, 2],
icp4 => [16, 1],
icp5 => [32, 1],
icp6 => [64, 1],
is32 => [16, 0],
il32 => [32, 0],
s8mk => [16, 0],
l8mk => [32, 0],
);
our($opt_t);
getopts("t");
unless (scalar @ARGV) {
die "Usage: unosxicns.pl [-t] icon.icns [...]\n";
}
my %cache;
my $dirname = dirname($0);
foreach my $argv (@ARGV) {
foreach my $glob (glob $argv) {
my $filename = $glob;
my $basename = basename $filename, qw{.svg .png .ico .icns .icowin .iconset};
my $folder = $basename . ".iconset";
$filename = $basename . ".icns";
next if $cache{$filename};
die "file not found $filename\n" unless -f $filename;
$cache{$filename} = 1;
&mkpict($filename, $folder);
}
}
exit;
# アイコンブロックの抽出
sub mkpict {
my $filename = shift;
my $folder = shift;
my %maskset;
mkdir $folder, 0777;
my($FH, $GH, $buff);
unless (open $FH, "<", $filename) {
die "Cant read input\n";
}
binmode $FH;
# 先頭のアイコンヘッダ8バイトの取得
die "Read error\n" unless sysread($FH, $buff, 8);
my($filetype, $filesize) = unpack "A4N", $buff;
die "Not ICNS format\n" unless $filetype eq "icns";
die "File size unmatch\n" unless $filesize == -s $filename;
say "$filename filesize $filesize";
# 各アイコンブロックについて
while (sysread $FH, $buff, 8) {
my($set, $ext, $outname, $scale);
# アイコンブロックヘッダ8バイト
my($icontype, $blocksize) = unpack "A4N", $buff;
my $readsize = $blocksize - 8;
die "Format broken\n"
if $readsize < 1
or $readsize != sysread $FH, $buff, $readsize;
say " type $icontype length $blocksize";
# 未知のアイコンタイプ
unless ($set = $iconset{$icontype}) {
say " undefined icon type";
next;
}
# PNGまたはJPEG2000形式
if ($scale = $set->[1]) {
# PNGヘッダ確認
$ext = ("\x89\x50\x4E\x47" eq substr $buff, 0, 4) ? "png" : "jp2";
# -t付きの場合はアイコンタイプをファイル名にする
if ($opt_t) {
$outname = sprintf "%s.%s", $icontype, $ext;
}
# 倍密解像度の場合のファイル名
elsif ($scale == 2) {
$outname = sprintf "icon_%ux%u\@x2.%s",
$set->[0] >> 1, $set->[0] >> 1, $ext;
}
# 標準解像度のファイル名
else {
$outname = sprintf "icon_%ux%u.%s",
$set->[0], $set->[0], $ext;
}
say "\t$outname size $readsize";
# 画像データ書出
unless (open $GH, ">", "$folder/$outname") {
die "Cant write output\n";
}
binmode $GH;
print $GH $buff;
close $GH;
next;
}
# MacOS8.5 mask raw format
if ($icontype =~ /(s8mk|l8mk)/) {
die "Mask broken, length missmatch\n"
unless $set->[0] ** 2 == length $buff;
$maskset{$icontype} = [unpack "C*", $buff];
}
# MacOS8.5 Color PackBits format
if ($icontype =~ /(is32|il32)/) {
my @data;
my @input = unpack "C*", $buff;
# PackBits unpack
while (scalar @input) {
my $code = shift @input;
if ($code < 128) {
push @data, splice @input, 0, $code + 1;
}
else {
my $next = shift @input;
push @data, ($next) x ($code - 125);
}
}
die "PackBits broken, length missmatch\n"
unless $set->[0] ** 2 * 3 == scalar @data;
$maskset{$icontype} = \@data;
}
}
close $FH;
&mkpngicon(\%maskset, "il32", "l8mk", $folder);
&mkpngicon(\%maskset, "is32", "s8mk", $folder);
}
# RGBAからPNGファイルへの変換
sub mkpngicon {
my $maskset = shift;
my $type = shift;
my $color;
return unless $color = $maskset->{$type};
my $mask = shift;
my $folder = shift;
$mask = $maskset->{$mask};
my $size = $iconset{$type}->[0];
my $sqrt = $size ** 2;
my $image = Image::Magick->new(magick=>'png', size=>"${size}x${size}", matte=>"True");
$image->Read("NULL:");
for (my $y = 0; $y < $size; $y++) {
for (my $x = 0; $x < $size; $x++) {
my @pixcels;
$pixcels[0] = $color->[$y * $size + $x] / 255; # R
$pixcels[1] = $color->[$y * $size + $x + $sqrt] / 255; # G
$pixcels[2] = $color->[$y * $size + $x + $sqrt * 2] / 255; # B
$pixcels[3] = $mask ? 1.0 - $mask->[$y * $size + $x] / 255 : 0.0; # A (Opacity)
my $err = $image->SetPixel(x=>$x, y=>$y, color=>\@pixcels, channel=>'RGBA', normalize=>'True');
say $err if $err;
}
}
my $outname = sprintf "icon_%ux%u.png", $size, $size;
# -t付きの場合はアイコンタイプをファイル名にする
if ($opt_t) {
$outname = sprintf "%s.png", $type;
}
say "\t$outname";
my $err = $image->Write("$folder/$outname");
die $err if $err;
}
1;
__END__
mkosxicns.pl
これでようやく本題に辿り着いた。mkosxicns.pl
はunosxicns.pl
とは逆に .pngファイルから .icnsファイルを生成する。ActivePerl(5.10+)と ImageMagick(PerlMagick)の他、パスの通った場所に PhantomJS
4がインストールされている必要がある。iconrast.js
は PhantomJS
スクリプトで mkosxicns.pl
と同じ場所に置く。OSX他でもMacPortsで同等の環境を揃えれば動作する。
使い方は引数に-b
オプションと .iconsetディレクトリを指定すると、iconutilと同等の動作を行って同名の .icnsファイルを生成する。
mkosxicns.pl -b foo.iconset
もうひとつの使い方は-m
オプションとSVGファイル5を指定することで、iconutilが必要とする10個のPNGファイルを含む .iconsetディレクトリを自動的に生成する動作だ。
mkosxicns.pl -m foo.svg
両者は-mb
オプションを指定して実行することで同時に行うことが出来る。すなわち以下の場合 foo.svgファイルから foo.iconsetディレクトリを作成し foo.icnsファイルを生成する。
mkosxicns.pl -mb foo.svg
#!/usr/bin/perl
# $Id: mkosxicns.pl 122 2015-03-16 04:06:57Z askn $ UTF8
use 5.010;
use strict;
use warnings;
use File::Basename;
use Image::Magick;
use Getopt::Std;
my @fileset = (
[128, "icon_128x128.png", "ic07"],
[256, "icon_256x256.png", "ic08"],
[512, "icon_512x512.png", "ic09"],
[1024, "icon_512x512\@2x.png", "ic10"],
[32, "icon_16x16\@2x.png", "ic11"],
[64, "icon_32x32\@2x.png", "ic12"],
[256, "icon_128x128\@2x.png", "ic13"],
[512, "icon_256x256\@2x.png", "ic14"],
[32, "icon_32x32.png", "icp5"],
[16, "icon_16x16.png", "icp4"],
);
our($opt_m, $opt_b);
getopts("mb");
unless (($opt_m || $opt_b) && 1 == scalar @ARGV) {
die "Usage: mkosxicns.pl -mb icon[.svg] [...]\n";
}
my %cache;
my $dirname = dirname($0);
my $phantomjs = "phantomjs";
my $rasterize = $dirname . "/iconrast.js";
foreach my $argv (@ARGV) {
foreach my $glob (glob $argv) {
my $filename = $glob;
my $basename = basename $filename, qw{.svg .png .ico .icns .icowin .iconset};
my $folder = $basename . ".iconset";
if ($opt_m) {
$filename = $basename . ".svg";
die "file not found $filename\n" unless -f $filename;
}
next if $cache{$filename};
say $filename;
$cache{$filename} = 1;
&mkpng($filename, $folder) if $opt_m;
&mkico($basename, $folder) if $opt_b;
}
}
exit;
# SVGからPNGへの変換
sub mkpng {
my $filename = shift;
my $folder = shift;
mkdir $folder, 0777;
foreach my $set (@fileset) {
printf "%s/%s\n", $folder, $set->[1];
system $phantomjs , $rasterize, $filename, $set->[0] , $folder . "/" . $set->[1];
}
say "complete";
}
# ICNSファイル作成
my @maskset;
sub mkico {
my $basename = shift;
my $folder = shift;
my $FH;
unless (open $FH, ">", $basename . ".icns") {
die "Cant write output\n";
}
binmode $FH;
my $total = 0;
my $data = "";
my @iconset;
foreach my $set (@fileset) {
# PNGファイル読込
my $path = $folder . "/" . $set->[1];
my $blob;
if (open my $FI, "<", $path) {
binmode $FI;
$blob = join '', <$FI>;
close $FI;
}
unless (scalar $blob) {
die "Cant read $path\n";
}
# MacOS8.5形式への変換
if ($set->[2] eq "icp4") {
&buildmaskset($path, "is32", "s8mk", 16, 1, 3);
next;
}
elsif ($set->[2] eq "icp5") {
&buildmaskset($path, "il32", "l8mk", 32, 0, 2);
next;
}
# アイコンタイプヘッダ8バイトの付加
my $length = length $blob;
$data .= pack("A4N", $set->[2], $length + 8);
$data .= $blob;
printf "%u %s\n", length($data), $path;
}
# 全アイコンブロック連結
$data .= join "", @maskset if scalar @maskset;
# アイコンヘッダ8バイトの付加
my $output = pack("A4N", "icns", length($data) + 8);
$output .= $data;
print $FH $output;
close $FH;
printf "%u complete", length($output);
}
# 24bitカラーと8bitマスク作成
sub buildmaskset {
my $path = shift;
my $name = shift;
my $mask = shift;
my $size = shift;
my $idxc = shift;
my $idxm = shift;
# PNGファイルを読み込んで全ピクセルを取得
my $image = Image::Magick->new(magick=>'png');
$image->Read($path);
$image->Set(magick=>'png');
my @input = $image->GetPixels(x=>0, y=>0,
width=>$size, height=>$size, map=>"RGBA", normalize=>"true");
# カラープレーン・マスクプレーンに分割
my @plane = ([], [], [], []);
for (my $y = 0; $y < $size; $y++) {
for (my $x = 0; $x < $size; $x++) {
for (my $i = 0; $i < 4; $i++) {
push @{$plane[$i]}, int(255 * shift(@input));
}
}
}
# カラープレーンをPackBits
my @encode = ("", "", "", pack("C*", @{$plane[3]}));
for (my $i = 0; $i < 3; $i++) {
my $buff = pack "C*", @{$plane[$i]};
while (length $buff) {
$encode[$i] .= chr(127) . substr($buff, 0, 128, "");
}
}
# 各データを連結してヘッダ8バイトを付加
my $buff = join "", @encode[0..2];
$maskset[$idxc] = pack("A4N", $name, length($buff) + 8) . $buff;
$maskset[$idxm] = pack("A4N", $mask, length($encode[3]) + 8) . $encode[3];
}
1;
__END__
// $Id: iconrast.js 121 2015-03-13 10:01:44Z askn $
var page = require('webpage').create(),
system = require('system'),
address, output, size;
if (system.args.length != 4) {
console.log('Usage: rasterize.js URL size filename');
phantom.exit(1);
}
else {
address = system.args[1];
size = system.args[2];
output = system.args[3];
page.viewportSize = { width: size, height: size };
page.open(address, function (status) {
if (status !== 'success') {
console.log('Unable to load the address!');
phantom.exit();
} else {
window.setTimeout(function () {
page.render(output);
phantom.exit();
}, 200);
}
});
}
// End of script
ダウンロード
本項で述べた mkosxicns.pl unosxicns.pl iconrast.js の3点セット。
もしPNGヘッダではないアイコンブロックであった場合は JPEG2000と仮定して .jp2ファイルで出力する。 ↩
ActivePerl http://www.activestate.com/activeperl ↩
ImageMagick http://www.imagemagick.org
・・・正直使用している機能に対してオーバースペックなのだがMachintosh/Windows両方で動作する画像操作ライブラリは希少なので困ってしまう。 ↩PhantomJS http://phantomjs.org
・・・CLIで動作するWebkitヘッドレスブラウザ。ここではSVGファイルから任意画素数の透過PNG画像を生成するのに使用している。 ↩SVGファイルはWidth値とHeight値が共に無指定(つまり規定値の'100%')である必要がある。固定値が指定されていると意図した大きさのアイコン画像とはならない。 ↩