BLOG ブログ


2024.03.07 TECH

PNGは何からできている?~JSでバイナリを読んでみる~

皆さんが普段当たり前に使っている「PNG」画像。
今回はPNGがどのような要素から成り立っているのかを、JavaScriptを用いて調べてみます。

ちょっと実験

試しに、PNGファイルから拡張子「.png」を取り除き、VSCodeで開いてみます。

このように表示されます。どうやらVSCodeは拡張子がない場合、これがPNG画像だと判別できないようです。
では次に、同じ画像をGoogle Chromeで開いてみます。

なんと、今度は正常表示されました!
つまり、Chromeは画像の拡張子がなくとも、「何か」を見てこれがPNG画像であると判断していることになります。では一体、その「何か」とは何なのでしょうか。

PNGファイルの構成

突き詰めていくと、PNGを構成しているものは何でしょうか。それはバイナリデータ、つまり0と1の羅列、或いはそれらを読みやすくまとめた8進数、16進数です。私たちが目で見ても、それは0と1の複雑な組み合わせにしか見えませんが、もちろんこの0と1には意味があります。
PNGの場合、このバイナリデータは「シグネチャ」と「チャンク」の二種類に分かれます。

シグネチャ

PNGの先頭8バイトはシグネチャとして扱われます。シグネチャこそが、「このファイルの形式はPNGである」、ということをアプリケーションに示しています。

チャンク

PNGが保持する情報のカタマリです。PNGファイルは複数のチャンクを持つことができます。種類が多く、イメージデータを表すもの、透明度を示すもの、ガンマ値を保持するものなどがあります。チャンクは「length」、「type」、「data」、「CRC」から構成されます。

JSでバイナリを扱う

先ほども述べたように、PNGとはバイナリデータです。このバイナリデータをJavaScriptで扱うにあたり、必要となる機能について見ていきましょう。
JSでバイナリを扱う方法は、「ArrayBuffer」「DataView」「TypedArray」の三種類あります。

ArrayBuffer

固定長のバイナリデータを表すオブジェクト、またはそれを生成するためのコンストラクタです。ここで生成された内容を直接操作することはできません。読み書きをするには、後述するDataViewまたはTypedArrayを利用します。
Base64やローカルファイルからArrayBufferを取得することもできます。

DataView

実はバイナリデータには、幾つか方言のようなものがあります。これをエンディアンといいます。具体的な違いはここでは語りません。エンディアンはプラットホームによって異なるのですが、DataViewはエンディアンにかかわらず、一定の操作を可能にしてくれます。DataViewは一種のインタフェースであり、これによって私たちはエンディアンを考慮せずにコードを記述することができるのです。

DataViewのインスタンスメソッドは、バイナリデータに対して読み書きが可能です。この際、バイナリデータを何ビット単位で扱うかを選択できます。インスタンスメソッドは各ビット長に対応するset・getメソッドを持ちます。

TypedArray

ArrayBufferオブジェクトに対し、読み書きを行う為に使用します。DataViewとは異なり、配列ライクにバイナリを扱うことができるのが特徴です。
型が複数あり、符号(Int、Uint)を扱うか、小数点を扱うか(Float、Double)、ビット長はいくつか、などによって使い分けます。本記事ではUint8Arrayを用いることにします。

※IntやFloatについて
普段JavaScriptをメインで触っている人にはなじみが薄いかもしれません。
多くの言語においては、数値の変数を定義する際に、その「数値の型」を決める必要があります。マイナスも含めた整数値ならInt型、正の整数値ならUint型、小数点も扱いたいならFloat型、より大きな数値を扱いたいならDouble型となります。JSでは基本的に、これら全てを集約してNumber型として扱います。

シグネチャを抜き出してみる

先ほども書きましたが、シグネチャとはPNGファイルの先頭8バイトのことです。というわけで、まずはさっくりと最初の8バイトを取得してみましょう。
色々と楽なので、今回はVueでフォームを作ります。

<template>
  <div id="app">
    <h1>PNGを分解する</h1>
    <input type="file" accept="image/png" name="pngImg" id="pngImg" @change="pngCheck">
    <div class="result">
      <dl class="result_signature">
        <dt>シグネチャ</dt>
        <dd>
          <span v-for="item in signature" :key="`signature-${item}`">
            {{item}},
          </span>
        </dd>
      </dl>
    </div>
  </div>
</template>

scriptも書いていきます。取得した画像に操作を行う場合は、FileReader()を利用しましょう。

<script setup>
  import { ref } from 'vue'
  const signature = ref([])
  const IDAT = ref([])
  
  const pngCheck = (e) => {
    const pngImg = e.target.files[0]
    const fileReader = new FileReader()
    let decodeData
    fileReader.addEventListener('load', () => {
      decodeData = fileReader.result
    })
    fileReader.readAsArrayBuffer(pngImg)
  }
</script>

fileReaderは、ユーザの指定したローカルファイルを非同期に読み込むことができます。ちなみによく見かける「ユーザが登録しようとしたファイルのサムネを一次的に表示する」仕組みもこれを利用しています。
そうして取得した画像を、ここでは8ビット符号なし整数値の配列として扱います。

decodeData = fileReader.result
      
const uint8 = new Uint8Array(decodeData)

Uint8Arrayは新しいUint8Arrayオブジェクトを生成するメソッドです。生成されたオブジェクトは(通常の配列のように)sliceが利用できるので、これで目的の配列を切り出します。

signature.value = uint8.slice(0, 8)

こうしてシグネチャを取得することができました! 表示はこのようになっている筈です。

どのようなPNGファイルを登録しても、この値は絶対に同一です。よって、アプリケーションはシグネチャを見ることで、ファイル形式がPNGであると判断できます。

ところで、この「137, 80, 78, 71, 13, 10, 26, 10」について、何故この数字がPNGを表すことになるのか、疑問が湧いてくるかもしれません。勿論、この数列には意味があります。

これを16進数に直すと「89 50 4E 47 0D 0A 1A 0A」となります。そしてGoogleなどで、「ASCIIコード表」と検索し、出てきた表とこの数字を見比べてみてください。
最初の「89」はASCIIコードに存在しない数値であり、「これはテキストデータではない」とアプリケーションに対して示しています。
続く「50 4E 47」はASCIIコード表に存在しますね。これらはASCIIコードで「PNG」になります。つまり正にこの数列が、このファイルがPNGファイルであることを示しているのです。
「0D 0A」はそれぞれ「CR LF」、改行の制御文字です。改行コードはOSによって異なりますが、ここでファイルシステムがその違いを吸収しているそうです。
「1A」は「End Of File(EOF)」。ファイル終端を表すため、PNGをテキストファイルとして読み取った場合、ここで読み込みが終了します。
最後の「0A」も改行問題を吸収するために付与されています。

以上がPNGファイルのシグネチャであり、このファイルがPNGであることをソフトウェアに示す符号になっています。

チャンク

次にチャンクを見ていきましょう。チャンクとは先述した通り、PNGファイルが持つ情報の塊です。各チャンクは「length(4バイト)」、「type(4バイト)」、「data(可変長)」、「CRC(4バイト)」からなります。
lengthはその名の通り、dataの長さを示しています。CRCはデータの破損チェックに用いられ、typeとdataから計算されます。

また、チャンクには数多くの種類があります。
ここではまず必須チャンクについて説明し、そのあとで幾つかの補助チャンクを挙げます。

必須チャンク

IHDR

イメージヘッダ。PNGファイルは必ずこのチャンクを一つだけ持ちます。lengthは必ず13、typeは「49 48 44 52(ASCIIコードでIHDR)」、dataには画像のwidth、height、ビット深度、カラータイプ、圧縮手法、フィルター手法、インターレース手法を持ちます。

IDAT

イメージデータ。lengthは可変長、typeは「49 44 41 54」です。dataには実際表示される画像のデータそのものを持ちます。

IEND

イメージの終端。つまりこのチャンクを以て、アプリケーションはファイルの終わりを認識しています。lengthは0。typeは「49 45 4E 44」、dataはありません。

補助チャンク

補助チャンクは、読み込むアプリケーションによっては必ずしも必要ではありません。ガンマ値やホワイトバランス、使われている色空間の情報は、画像編集のために利用するソフトウェアもある一方、単に画像を表示するだけなら不必要な情報です。

ところで、「Fireworks」というアプリケーションのことを覚えているでしょうか。マクロメディアが制作し、のちにAdobeに引き継がれた、Webデザインアプリケーションです。イラストレータの拡張子は「.ai」、フォトショップなら「.psd」ですが、Fireworksの拡張子は「.fw.png」でした。fwが付いているものの要はPNGであり、Fireworks以外のソフトウェアで開いた場合は、単なる画像として表示することができたのです。
では、Fireworks用に必要な、レイヤー情報などは一体どこに保存されていたのか。その場所がつまり、補助チャンクなのです。PNGファイルは好きな数だけ補助チャンクを持つことができます。実際にFireworksで使われていたチャンク名については、残念ながら調べても見つかりませんでした。ですがPNGに任意のチャンクを追加することで、データを保存していたであろうことは推測できます。

gAMA

ガンマ値。lengthは常に4、typeは「gAMA」であり、dataは画像のガンマ値を表します。

cHRM

ホワイトバランス。lengthは常に32、typeは「cHRM」。dataは「各色のX値とY値」からなります。白、赤、緑、青のXとYでそれぞれ4バイトずつ使うので、合計で32バイトとなります。

sRGB

標準RGBカラースペース(色空間)。ひとつだけ設置可能です。このチャンクが存在すると、ソフトウェアはこのPNGファイルが利用する色空間がsRGBであると認識できます。同種のチャンクにはiCCPがあります。

チャンクを抜き出してみる

それではここで、試しにIDATを取得してみましょう。

とりあえず出力側を作ります。

      <dl class="result_idat">
        <dt>IDAT</dt>
        <dd>
          <span v-for="item in IDAT" :key="`idat-${item}`">
            {{item}},
          </span>
        </dd>
      </dl>

次にJSですが、まずは任意のチャンク名をASCIIコードに変換し、バイナリの中から探す関数を作ります。

      const getChunkData = (chunkName) => {
        const chunkNameArr = []
        for (let i in chunkName){          
          // チャンク名をASCIIに変換
          chunkNameArr.push(chunkName.charCodeAt(i))
        }
        // バイナリからチャンク名の位置を探す
        const cIndex = uint8.findIndex((item, index, arr) => {
          if(item === chunkNameArr[0] && arr[index + 1] === chunkNameArr[1] && arr[index + 2] === chunkNameArr[2] && arr[index + 3] === chunkNameArr[3]) return true
        })
        if (cIndex === -1) return []
      }

今回はバイナリデータ内に置ける指定したチャンクの「位置」が欲しいので、Uint8Array.prototype.findIndex()を使用しています。ちょっとコードがややこしいですが、4文字からなるチャンク名に一致する部分を探し出し、1文字目のindexを返しています。

ここからは、上記の関数にチャンクを取得するための処理を追加していきます。

dataを取得するにはlengthが必要です。lengthはチャンク名の手前4バイトなので、まずはこれをsliceで取得します。ただこのままでは、取得した4バイトは8進数の配列であり、ここから(我々が使いやすい形で)長さを読み取ることができません。そこで、一旦配列をそれぞれ2進数に変換し結合したものを、10進数の数値へと変換しています。
これでlengthを取得することができました。

        // 取得した位置の手前4バイトがlengthなので、このデータを取得する
        const cLengthArr = uint8.slice(cIndex - 4, cIndex)
        // この4バイトをそれぞれ2進数に変換して結合する
        let cLengthStr = ''
        for (let item of cLengthArr) {
          cLengthStr += item.toString(2)
        }
        // 2進数の文字列を10進数の数値に変換=lengthで示されたバイト数
        const cLength = parseInt(cLengthStr, 2)

lengthが取れれば、dataの長さが判明しますね。というわけで、チャンク名の直後からlengthの長さ分のデータ取得すれば、dataも取ることができます。

        // lengthで示されたバイト数分のdataを取る
        const cData = uint8.slice(cIndex + 4, cIndex + 4 + cLength)

最後にCRCを取得します。これはチャンク名4バイトとlengthのあとに続く4バイトなので、単純に始点を設定して4バイトを取得すればよいです。

        // CRCを取る
        const cCrc = uint8.slice(cIndex + 4 + cLength, cIndex + 4 + cLength + 4)

以上がチャンクの構成要素です。
折角なので取れたものを結合してもよかったのですが、TypedArrayにはconcatがなく、あらかじめ必要な領域を確保した上でsetを利用する……、あるいはnew Uint8Arrayで作り直す……、というややややこしい操作が必要だったので、ここでは改めて、開始点からチャンクの終端までをsliceしています。

        // 開始点からデータ長(4)、チャンク名(4)、データ(可変)、CRC(4)までのバイナリを返す
        const targetChunkData = uint8.slice(cIndex - 4, (cIndex - 4) + cLength + 12)
        return targetChunkData

では、できあがったgetChunkDataに、「IDAT」というチャンク名を渡してみます。

IDAT.value = getChunkData('IDAT')

画面表示はこのようになります。

ここまでのコードはこちらを参照してください。
https://codepen.io/nd_akikofujii/pen/JjzpLyb

本当に取れたのか?

ここまで読んだあなたは、こう思ったかもしれません。

これ本当にチャンク取れてんの?

と。
確かにこの数字の羅列を見ただけでは、チャンクが取れているかどうかは今一分かりませんね。
というわけで、最後に本当にチャンクが取れているのかを確認していきます。要するに、取得できたシグネチャと必須チャンクを結合して、PNGファイルが出力できれば良いわけです。

      IHDR.value = getChunkData('IHDR')
      IDAT.value = getChunkData('IDAT')
      IEND.value = getChunkData('IEND')
      
      
      const newPng = new Uint8Array([
        ...signature.value,
        ...IHDR.value,
        ...IDAT.value,
        ...IEND.value
      ])

そしてこのバイナリデータをBase64に変換します。なおここではbtoaを用いて変換を行っていますが、どうやらあまりファイルサイズが大きいと、btoaでは変換しきれないようです。今回は7kb程度の画像を用いました。

      const base64encode = (data) => {
        return btoa([...data].map(n => String.fromCharCode(n)).join(''))
      }
      
      displayFile.value = 'data:image/png;base64,' + base64encode(newPng)

このBase64をimg要素の:srcに渡せば画像が表示されます。

    <div class="result">
      <dl class="result_img">
        <dt>画像</dt>
        <dd>
          <img :src="displayFile" alt="">
        </dd>
      </dl>
    </div>

無事、シグネチャと必須チャンクの組み合わせで画像が再現できることが分かりました。つまり、先ほどのコードで正しくチャンクが取れていたということになります。

画像再結合のコードはこちら。
https://codepen.io/nd_akikofujii/pen/bGZOGQG

おわりに

普段、JavaScriptを書く際にバイナリに触れることはなかなかありません。ましてや画像データの中身なんて気にすることもないと思います。しかし実際、PNGはこのようにできているし、それをJSで編集する方法も存在します。もし何かを実装するときに、このことがなんらかのヒントになれば幸いですが、そうでなくとも、意外とJSにできることは多い、他にもできることがあるかもしれない、と思っていただけたなら幸いです。


Kakko
ライター名:Kakko
草を育て、魚を愛で、本を読み、刺繍をするフロントエンドエンジニア。

主な記事一覧へ

一覧に戻る


LATEST ARTICLE 最新の記事

CATEGORY カテゴリー