[Astro] #169 PSD / PSB Viewer — レイヤー一覧ビューアを、自前パーサーで合成・マスク・書き出しまで作り直し、1GB超の PSB を開く

[Astro] #169 PSD / PSB Viewer — レイヤー一覧ビューアを、自前パーサーで合成・マスク・書き出しまで作り直し、1GB超の PSB を開く

はじめに

前回は、サイトマップの仕上げでした。

今回は、プロジェクトマップにあった「PSD / PSB Viewer」です。先に、自分で 1 つ作ってありました。ただ、できることは、レイヤーの名前と大きさを並べて、1 枚ずつ表示するところまででした。

それを、動かしながら読んで、悪いところを直して、足せる機能を足しました。作り直したあとは、体感で 10 倍以上は速くなり、1GB を超えるファイルも、一瞬で開きます。数字での計測は、していません。

今回の要点は、次の 6 つです。

  1. ファイル全体を読まずに、4MB の窓で読む
  2. プレビューに必要な行だけを、デコードする
  3. 合成画像(Photoshop が保存した結果)と、レイヤー合成を、切り替えられるようにする
  4. グループ、マスク、ブレンド、クリッピング、塗りレイヤー、ベクターマスク、アートボードを合成する
  5. 公開されているテストファイルと、1 枚ずつ比べる
  6. 未対応の機能は、画面に出す

完成画面

61 レイヤー + 15 グループの PSD

レイヤーツリーと、合成されたテクスチャが表示された PSD / PSB Viewer

2048×2048、12.12MB、61 レイヤー + 15 グループの PSD です。右にレイヤーツリー、左にファイル情報と、未対応の機能の注意書きが出ます。

※公開されているVketちゃんのデータをお借りしています。

20000×20000、1.13GB の PSB

20000×20000、1.13GB の PSB を、20% のプレビューで表示した画面

テスト用に作った、20000×20000、1.13GB の PSB です。中身はノイズで、レイヤーはありません。4096px のプレビュー(元の 20%)で、すぐ表示されます。

元のコードで見つけた不具合

作り直す前に、元のコードを読んで、悪いところを洗い出しました。見つけたのは、次のとおりです。

  • カラーモードの名前が、逆でした(1 が Grayscale で、2 が Indexed なのに、逆に表示していた)
  • 圧縮は、RLE だけでした。ZIP と ZIP+Predict は、読めません
  • レイヤーの行の位置を、毎回、先頭から足し算していました(O(H²)。大きなファイルで遅くなる)
  • グループ、マスク、ブレンドモード、クリッピングを、考えていませんでした
  • レイヤー名を、Unicode 名(luni)ではなく、昔の文字列から読んでいました(日本語名が化ける)
  • 16/32bit、CMYK、Lab、Indexed、Bitmap に、対応していませんでした
  • 合成画像のアルファを、白の上に焼かれたままで扱っていました
  • 「互換性を最大にする」を切って保存したファイル(合成画像が、ダミー)を、見分けていませんでした

速さの差は、主に 3 つ目(行の位置)と、「必要な行だけ読む」作りの違いから来ていると考えていますが、どれがどれだけ効いたかは、分けて測っていません。

ファイルを、窓で読む

PSD は、先頭から順に、ヘッダー、カラーモード、画像リソース、レイヤー情報、合成画像、と並んでいます。PSB は、同じ並びで、長さを表す数値が、4 バイトではなく 8 バイトの場所があります。

1GB を超えるファイルを、arrayBuffer() で丸ごと読むと、メモリが足りなくなります。そこで、Blob.slice() で、必要な場所だけ、4MB 単位の窓で読みます。

  • 窓の中にある範囲は、メモリから返す
  • 窓の外なら、その場所から、新しく読み直す
  • 読み終わった窓は、捨てる

読むのは、ヘッダーと、各レイヤーの情報(名前、位置、チャンネルの長さ)と、画面に出す分だけです。合成画像だけを見るときは、レイヤーの画素には触りません。レイヤー表示にしたときは、各レイヤーを、プレビューの大きさで順にデコードします(進み具合を表示)。

PSB の 8 バイト長

PSB で、長さが 8 バイトになるのは、レイヤー情報やチャンネルの長さ、それから、いくつかのタグ(LMsk、Lr16、Lr32、Layr など)です。タグの長さを読むところを、PSB のときだけ 8 バイトにしました。

// PSB の長いタグ(LMsk, Lr16, Lr32 など)は、長さが 8 バイト
s.pos -= 4;
len = Number((await s.view(8)).getBigUint64(0));

最初は、ここを、読み替えの途中で、ややこしい書き方にしていました。位置を 4 バイト戻して、8 バイトで読み直す形に、直しました。

圧縮の読み方

チャンネルの圧縮には、4 種類あります。

番号方式読み方
0Rawそのまま
1RLE(PackBits)行ごとのバイト数の表を読んで、行ごとに展開
2ZIPDecompressionStream('deflate')
3ZIP + PredictZIP のあと、差分を元に戻す

RLE は、行の位置を足し込む

RLE は、最初に、各行の圧縮後のバイト数が並んでいます。行の位置は、これを先頭から足せば出ます。元のコードは、行ごとに先頭から足し直していたので、行数の 2 乗の時間がかかっていました。今回は、1 回だけ足して、表にしておきます。そうすると、欲しい行だけを、すぐに読めます。

PSB では、このバイト数が、2 バイトではなく 4 バイトです。

ZIP + Predict

ZIP + Predict は、画素を差分にしてから圧縮してあります。戻す方法は、ビット深度で違います。

  • 8bit:同じ行の中で、前の値を足していく
  • 16bit:同じ行の中で、16bit の単位で足していく
  • 32bit:先に、バイトを並べ直す(上位バイトから順に並んでいる)。そのあと、バイトごとに足していく

32bit が、いちばん面倒でした。

ビット深度は、8bit にそろえる

画面に出すときは、全部 8bit にそろえます。

  • 1bit:白黒に開く
  • 16bit:上位 8bit を使う
  • 32bit(浮動小数):sRGB のガンマをかけて、8bit にする

32bit は、ag-psd が 1/2.2 のガンマで変換しているので、sRGB の曲線と、完全には同じになりません。

CMYK と Lab

CMYK は、PSD の中で、反転して保存されています(255 が、インクなし)。そこで、c × k / 255 のような、単純な変換で RGB にしています。ICC プロファイルは、読み込みますが、適用はしていません。Lab は、D50 の白色点で、sRGB に変換します。

プレビューは、必要な行だけデコードする

20000×20000 の画像を、全部、展開すると、400MB(RGBA で 1.6GB)になります。画面に出すのは、4096px ほどです。そこで、デコードするときに、出力の座標から、元の座標を逆算して、必要な行と列だけを取り出します。

// 出力の i 番目の画素は、元画像の何番目か(範囲外は -1)
const map = makeMap(outN, docStart, scale, srcStart, srcLen);
  • 使わない行は、展開しません(RLE は、行の位置の表があるので、読み飛ばせる)
  • 取り出しは、最近傍です(平均はとらない)。そのため、細い線が、消えることがあります
  • プレビューのサイズは、画面から、1024 / 2048 / 4096 などで選べます。大きくするほど、細かく見えます

画面には、「Preview is downscaled to 20%(nearest-neighbour)」と、はっきり出します。

合成画像と、レイヤー合成

PSD には、2 通りの絵が入っています。

  • 合成画像:Photoshop が、保存するときに、合成した結果
  • レイヤー:1 枚ずつの画素と、マスクや、ブレンドの設定

合成画像は、正確です。ただし、「互換性を最大にする」を切って保存すると、ダミー(真っ白など)が入ります。リソース 1057 の hasRealMergedData で、見分けられます。そのときは、自動でレイヤー合成に切り替えます。

レイヤー合成は、自分で、重ねます。こちらは、Photoshop の結果とは、少し違うことがあります。違いは、あとで書きます。

画面には、「Merged image only(exact, fast)」のチェックがあります。1GB の PSB は、これで、すぐ開きます。

白に焼かれたアルファ

合成画像にアルファがあるときは、白の上に合成された形で、保存されています(Photoshop の都合)。そのまま出すと、透明な所に、白い縁が出ます。そのため、白で割って、戻しています。

// 白の上に焼かれた色から、元の色を戻す
const ra = 255 / a, inv = 255 * (1 - ra);
rgba[o] = rgba[o] * ra + inv;

レイヤーのツリー

レイヤーは、フラットな配列で並んでいます。フォルダは、「区切り」のレイヤーで、表されます。

  • フォルダの終わり(ファイルの順では、先に出る)が、type 3
  • フォルダの始まり(開いているのが 1、閉じているのが 2)が、フォルダ自身のレコード

ファイルは、下のレイヤーから順に並んでいます。そこで、後ろから読んで、区切りで、入れ子にしていきます。グループのブレンドモードは、lsct のキーで、上書きされます。

グループは、隔離するか、しないか

「通過(Pass Through)」以外のグループは、中を、いったん別の画像に合成してから、下に重ねます(隔離)。通過は、中のレイヤーを、そのまま下の絵に重ねます。ただし、グループに、不透明度 100% 未満や、マスクがあるときは、隔離します。

レイヤーの合成

重ねるところは、canvas の globalCompositeOperation で、やっています。ブラウザでも、Node(@napi-rs/canvas)でも、同じコードで動きます。

  • ブレンドモード:canvas の名前に対応づけています(Multiply、Screen、Overlay など)。対応する名前がないもの(Dissolve、Hard Mix、Subtract、Divide など)は、近い見た目で近似しています。注意書きに、「近似している」と出します
  • 不透明度と、塗りの不透明度(iOpa):別々に扱います
  • クリッピング:下のレイヤーの形で、切り抜きます。一時的な canvas に描いてから、destination-in で切ります
  • レイヤーマスク:画素のアルファに、先に掛けてしまいます

グループにも、マスクがある

グループにも、マスクを付けられます。グループを、いったん別の画像に合成してから、マスクで切り抜いて、重ねます。

塗りレイヤーと、ベクターマスク

べた塗り(SoCo)と、グラデーション(GdFl)のレイヤーは、画素を持っていません。記述子(Descriptor)という、キーと値の入れ子の形式で、設定だけが入っています。これを読んで、自分で描きます。

  • べた塗り:色を、そのまま塗る
  • グラデーション:線形、放射、反射、円錐(Angular)を、canvas のグラデーションで描く。ひし形は、放射で近似する

形は、ベクターマスク(vmsk)で決まります。パスは、26 バイトのレコードの並びで、座標は、固定小数点(8.24)で、画像の幅と高さに対する割合です。

// 8.24 固定小数点 → 画像の座標
const fx = (o) => dv.getInt32(o) / 16777216;
// 順番は y, x。前の制御点、アンカー、後ろの制御点の順
const k = [fx(o + 4) * W, fx(o) * H, fx(o + 12) * W, fx(o + 8) * H, fx(o + 20) * W, fx(o + 16) * H];

パスの演算は、次のとおりです。

演算canvas の合成
合体source-over
型抜きdestination-out
交差destination-in
除外xor

アートボード

アートボードは、グループに、artb のタグが付いたものです。範囲と、背景の色が入っています。範囲の外は切り抜いて、背景の色があれば、先に塗ります。

テストと、見つけたバグ

自分の目で見るだけでは、見落としがあります。そこで、公開されている、2 つのリポジトリのテストファイルを使いました。

  • ag-psd:canvas.png(期待する合成結果)が付いている
  • psd-tools:テスト用の PSD / PSB が、たくさん入っている

Node で、同じ合成のコードを動かして、期待する画像と、画素ごとに比べました(誤差の平均を出す)。ブラウザでの操作は、Playwright(Chromium)で確認しました。

確認できたこと

  • デコード(Raw / RLE / ZIP / ZIP+Predict、各ビット深度、PSB):レイヤー単体のアルファは、完全に一致
  • マスクの濃度の、テスト用ファイル(mask-density-*):直したあと、誤差は 0〜0.3 ほど
  • パス演算の、テスト用ファイル(path-operations/*):誤差は、ほぼ 0
  • アートボード(ag-psd の artboards、psd-tools の artboard-bgcolor):完全に一致
  • Chromium で、読み込み、選択、表示の切り替え、検索、ズーム、ピクセル確認、PNG と ZIP の書き出しまで、一通り動かして、エラーは出ませんでした

最後に直した 5 つのバグ

テストで、差が出たところを、1 つずつ見ていって、見つかりました。

  1. マスクの濃度を、読んでいなかった。濃度が低いマスクは、「効きが弱い」はずなのに、全開で効いていた
  2. マスクのパラメータを、読む順番が逆だった。「実マスク → パラメータ」の順で、並んでいた
  3. ベクターマスクがあるとき、本物のレイヤーマスク(チャンネル -3)を、使っていなかった
  4. 「他のデータから描かれたマスク」を、ベクターマスクと、二重に掛けていた(flags の bit 3)
  5. アートボードの背景色の、キーの名前が、Clrではなく Clr (末尾に空白)だった

5 つ目は、名前が 1 文字違っただけで、色が読めず、白になっていました。画面で見ても、「白い板」としか見えないので、画素を比べないと、気付けませんでした。

自分で入れたバグ

直す途中で、自分で入れたバグもありました。ベクターマスクの濃度を、「値が undefined でなければ、掛ける」としていたところで、マスクがない(null)ときも、通ってしまい、濃度 0 として、全面が表示されました。!= null に直しました。

テストを回し直して、見つかりました。直したあとにも、もう一度、回すことが必要でした。

ベクターマスクの「初期塗り」

ベクターマスクには、「初期の塗り」というフラグがあります。「全面から始めて、パスを引く」という意味に読めます。ただ、実際のファイルを見ると、先頭のパスが「合体」のときは、パスの形だけが残っていました(全面は、残らない)。

そこで、次の決まりにしました。

  • 初期塗りがあって、パスがない:全面を表示
  • 初期塗りがあって、先頭のパスが「型抜き」か「除外」:全面から始める
  • それ以外:パスの形だけ

これは、手元の数ファイルの結果から決めた、経験則です。仕様書に、そう書いてあったわけではありません。外れる例が、あるかもしれません。

書き出し

  • 合成画像を PNG で:いま表示している状態を、書き出す
  • レイヤー 1 枚を PNG で:選んだレイヤーだけ
  • 全レイヤーを ZIP で:1 枚ずつの PNG と、layers.json(名前、グループ、位置、サイズ、不透明度、ブレンドモード)。ZIP は、自前で書いています

ZIP は、圧縮なし(stored)にしています。PNG が、すでに圧縮されているためです。

画面まわり

  • ズーム:ホイール、100%、画面に合わせる。大きく拡大したときは、ドットが見えるように、補間を切る
  • ピクセル確認:マウスの下の、RGBA と、16 進の色
  • 透明の背景:市松模様、白、黒、などから選べる。市松は、ズームに合わせて、大きさを変える
  • 選んだレイヤーの範囲:枠で表示
  • レイヤーの検索、全部開く / 閉じる、全部表示 / 非表示、Alt + 目玉で、そのレイヤーだけ表示

使わなかった範囲を、見せる

ファイルに入っている情報が、画面に出ていないときは、そう伝えます。調整レイヤー、レイヤー効果、シェイプの線、パターン塗りがあれば、「適用していない」と、左の注意書きに出します。見た目が、Photoshop と違うときに、「バグなのか、未対応なのか」が、分かるようにしたかったからです。

未対応のこと

  • 調整レイヤー(カーブ、レベルなど)
  • レイヤー効果(ドロップシャドウ、光彩、境界線など)
  • シェイプの線(塗りだけ描く)
  • パターン塗り
  • ノックアウト、ブレンド条件(Blend If)
  • テキストとスマートオブジェクトの、描き直し(ファイルに入っているラスタを、そのまま出す)
  • マスクのぼかし(値は読むが、適用しない)
  • 一部のブレンドモードは、近似
  • CMYK と Lab は、ICC プロファイルを使わない、簡易的な変換

差が残っているもの

  • 32bit のファイルで、塗りレイヤーの色が、Photoshop の結果とずれる例がある(vector-mask.psd)。理由は、まだ分かっていません
  • 4×4 のような、小さすぎるファイルのグラデーションは、角度が合わない例がある

どちらも、画像の数が少ないので、手元のファイルの範囲で、確認した結果です。

ハマりどころ

1. 見た目が合っていても、合っているとは限らない

アートボードの背景が白いままでも、画面で見ると、普通に見えます。期待する画像と、画素で比べて、初めて、色が違うと分かりました。

2. 「互換性を最大にする」が、切れているファイル

合成画像が、ダミーなので、「合成画像と、レイヤー合成を比べる」テストが、成り立ちません。比べる相手を、合成画像が本物のファイル(hasRealMergedData が true)に絞りました。

3. 差が大きく出ても、バグとは限らない

テストで、差が大きく出たファイルの多くは、調整レイヤーや、レイヤー効果を使っていました。未対応なので、差が出て当然です。それらを除いてから、残った差を、1 つずつ、調べました。

4. 小さなファイルは、ルールが読めない

4×4 のテスト用ファイルは、グラデーションの角度が、設定と合いませんでした。画素が、16 個しかないので、設定が正しくても、丸め方の違いが、大きく見えます。深追いをやめて、「合わない例がある」と、書くことにしました。

5. 仕様書にない所は、ファイルから決める

ベクターマスクの初期塗りのように、仕様書に書いていない所は、実際のファイルを見て、決めました。そのときは、「経験則」と、記録に残します。

おわりに

元は、レイヤーの一覧が出るだけでした。それが、重ねて、切って、塗って、書き出せる、ところまで来ました。1GB を超える PSB も、すぐ開きます。

一番、効いたのは、「全部を読まない」ことでした。窓で読んで、必要な行だけを展開して、合成画像だけを見るときは、レイヤーの画素に触りません。

次に足すなら、レイヤー効果(境界線とドロップシャドウ)と、調整レイヤー(レベルとカーブ)が、見た目の差を、いちばん減らせそうです。ただ、これは、見た目への効き目を、測ったものではなく、私の見込みです。

実装メモは、docs/psd-psb-viewer.md に、残してあります。