🖼️

非圧縮png/apng生成

に公開
2025/12/08

pngとapngの生成をやってみたので記録.コードが汚いのは許してください
圧縮の実装はしていません.圧縮まで実装すると流石に死ぬ

1. PNG

1.1 pngの構成

pngは主に以下の要素で成り立つ

  • IHDR:ヘッダー
  • IDAT:画像データ
  • IEND:エンドポイント

また,その他重要な概念としてバイトオーダー反転とCRCとAdlerがある.

1.1.1 バイトオーダー反転

pngはバイト単位で扱われるがこれを反転して書き込む.ビッグエンディアン(大きい順)というメモリ配置の規格.
x86などの一般的なCPUはリトルエンディアンを採用しているため,PNG(ビッグエンディアン)に合わせるために反転が必要
メモリ上で 0x12 0x34 (リトルエンディアン) と並んでいる値を,PNGの仕様に合わせて 0x34 0x12 (ビッグエンディアン) の順序でファイルに書き込む必要がある
ビット数に応じたバイト反転を用意してやる必要がある

バイト反転コードサンプル
//32ビット整数のバイトオーダーを反転させる
uint32_t swap_uint32(uint32_t val) {
    return  (val >> 24) | // 最上位バイトを最下位へ
            (val >>  8) | // 2番目のバイトを3番目へ
            (val <<  8) | // 3番目のバイトを2番目へ
            (val << 24);  // 最下位バイトを最上位へ
}
//16ビット整数のバイトオーダーを反転させる
uint16_t swap_uint16(uint16_t val) {
    return (val >> 8) | (val << 8);
}

1.1.2 CRC

PNGの各チャンクの末尾に,データが壊れていないか確認するための4バイトのチェックサムをつける必要がある.データを巨大な数値と見なして,マジックナンバーで割った余りをつけている.
マジックナンバー 0xEDB88320L は,IEEE 802.3標準の多項式を使う
毎回ビット演算すると遅いので事前計算テーブル(crc_table)を使って高速化してる

crcサンプルコード
// CRCテーブル,256個の計算済み値
static uint32_t crc_table[256];
static int crc_table_computed = 0;//初期化されたか

//CRCテーブルの初期化関数
void make_crc_table(){
    uint32_t c;
    int n, k;
    for (n = 0; n < 256; n++) {
        c = (uint32_t)n;
        for (k = 0; k < 8; k++) {
            if (c & 1)
                c = 0xEDB88320L ^ (c >> 1);//多項式
            else
                c = c >> 1;
        }
        crc_table[n] = c;
    }
    crc_table_computed = 1;
}
/*
CRC32を計算して返す
buf: 計算対象のデータ(チャンクタイプ + データ)
len: データの長さ
*/
uint32_t calc_crc32(const unsigned char *buf, size_t len) {
    uint32_t crc = 0xFFFFFFFFL; //初期値は全ビット1
    size_t n;
    if (!crc_table_computed) {
        make_crc_table();
    }
    for (n = 0; n < len; n++) {
        crc = crc_table[(crc ^ buf[n]) & 0xFF] ^ (crc >> 8);
    }
    return crc ^ 0xFFFFFFFFL; // 最後に全ビット反転
}

//PNGファイルにCRCを書き込む関数
void write_chunk_crc(FILE *fp, const unsigned char *chunk_type_and_data, size_t length) {
    // CRCを計算(リトルエンディアンのまま)
    uint32_t crc_val = calc_crc32(chunk_type_and_data, length);
    // ファイルに書き込むためにビッグエンディアンに変換
    uint32_t crc_be = swap_uint32(crc_val);
    // 書き込み
    fwrite(&crc_be, 4, 1, fp);
}

1.1.3 Adler

PNGの各チャンクにはCRC32が必要だが,画像データを圧縮するzlib(Deflate)の仕様として圧縮データの末尾にはAdler-32という別のチェックサムが必要になる.
全バイトの値の総和を,素数で割ったものになる.16ビット符号なし整数で最大の素数の65521が使われる.

Adlerサンプルコード
uint32_t calc_adler32(const unsigned char *data, size_t len) {
    uint32_t a = 1, b = 0; // 仕様によりaの初期値は1
    size_t index;
    for (index = 0; index < len; ++index) {
        a = (a + data[index]) % 65521; // データの累積和
        b = (b + a) % 65521;           // 累積和の累積和
    }
    // bを上位,aを下位ビットに配置して返す
    return (b << 16) | a;
}

ここまで用意してようやくチャンクが組み立てることができる

1.2 PNGシグネチャ(8バイト)

これを最初に入れておく.これがないとそもそもコンピュータが画像と認識してくれない.

    const unsigned char signature[] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
    fwrite(signature, 1, 8, fp);

1.3 IHDR

ヘッダーチャンク
作ってて知ったのだが,pngは思ったより設定項目が多い
設定項目

  • width, height:画像サイズ
  • bit depth:色値の精度,8bitか16bit.基本は8
  • color type:グレースケール,RGB,RGBAがある
  • compression method:常に0
  • filter method:0でいい.隣のピクセルとの差分を使って圧縮を高めるやつ.画像によってはこれでものすごく圧縮できる.
    0=None, 1=Sub(左ピクセルとの差分), 2=Up(上の行との差分), 3=Average(左と上の平均との差分), 4=Paeth(Paeth予測器)
  • interlace method:画像を7回に分けて粗→精細になるように並べる方式.
    ネット回線が遅い環境で「徐々に表示される」ができるらしい.0/1でON/OFF

compression methodは,もっといい圧縮方法が来たときのために取ってあるらしい

ファイルにはデータの長さ,タイプの名前,ヘッダーデータ,CRCの順番で書き込む.

IHDRサンプルコード
void png_IHDR(int width, int height, FILE* fp){
    // IHDRのデータ部(13バイト)
    unsigned char ihdr_data[13];    
    uint32_t w_be = swap_uint32(width);
    uint32_t h_be = swap_uint32(height);
    // ヘッダーデータの用意
    memcpy(ihdr_data + 0, &w_be, 4); // 幅
    memcpy(ihdr_data + 4, &h_be, 4); // 高さ
    ihdr_data[8]  = 8;  // Bit depth (8bit)
    ihdr_data[9]  = 2;  // Color type (0=グレー,2=RGB,6=RGBA)
    ihdr_data[10] = 0;   // Compression method (0: Deflate)
    ihdr_data[11] = 0;   // Filter method (0: Standard)
    ihdr_data[12] = 0;   // Interlace method (0: None)
    // チャンク書き込み
    uint32_t len_be = swap_uint32(13);
    fwrite(&len_be, 4, 1, fp);
    // タイプ("IHDR")書き込み
    const unsigned char type[] = "IHDR";
    fwrite(type, 1, 4, fp);
    // データ書き込み
    fwrite(ihdr_data, 1, 13, fp);
    // CRC計算
    unsigned char crc_buf[17];
    for(int i=0; i<4; i++) crc_buf[i] = type[i];
    for(int i=0; i<13; i++) crc_buf[4+i] = ihdr_data[i];
    uint32_t crc = calc_crc32(crc_buf, 17);
    //CRC書き込み
    crc = swap_uint32(crc);
    fwrite(&crc, 4, 1, fp);
}

1.4 IDAT

画像データ
やることが多い

まず画像データには各色データの先頭にフィルタタイプを付ける必要がある.非圧縮・RGBなら(0x00+RGB)の4バイトずつになる.そのため,事前準備として画像データのフィルタリングが必要になる.

フィルタ処理サンプルコード
unsigned char* create_filtered_data(const unsigned char *raw_data, uint32_t width, uint32_t height, size_t *out_size) {
    uint32_t bytes_per_pixel = 3;
    uint32_t line_raw_size = width * bytes_per_pixel;     // 元の1行サイズ
    uint32_t line_filtered_size = line_raw_size + 1;             // フィルタバイト込みの1行サイズ
    size_t total_size = line_filtered_size * height; // 全体のサイズ
    unsigned char *filtered_buf = (unsigned char *)malloc(total_size);
    for (int y = 0; y < height; y++) {
        // 書き込み先:行の先頭
        unsigned char *line_head = &filtered_buf[y * line_filtered_size];
        // フィルタタイプ (0x00: None) 
        line_head[0] = 0x00;
        // 画素データをコピー
        const unsigned char *src_line = &raw_data[y * line_raw_size];
        memcpy(line_head + 1, src_line, line_raw_size);
    }
    *out_size = total_size;
    return filtered_buf;
}

データが大きすぎるときはブロックを分割する.分割も 65521 を使った.
回線が遅いときに上から少しずつ読み込まれるのはこれが原因.interlace methodを使うときはやり方が変わると思われる.
zlib Headerは0x78 0x01を使っている.ヘッダの整合性(CMF*256+FLG % 31 == 0)を満たしてあればなんでもいいが,もしzlib実装を使うならzlibの出力を入れる必要があるらしい

idat初期化サンプルコード
    int MAX_BLOCK_SIZE = 65521;
    int line_size = config.width * bytes_per_pixel + 1;
    size_t filtered_data_size = line_size * config.height;
    // ブロックの必要数
    size_t num_blocks = (filtered_data_size + MAX_BLOCK_SIZE - 1) / MAX_BLOCK_SIZE;
    // IDATチャンクの中身の総サイズ
    // zlibヘッダ(2) + (ブロックヘッダ(5) * ブロック数) + 生データ総量 + Adler32(4)
    size_t idat_content_len = 2 + (num_blocks * 5) + filtered_data_size + 4;
    unsigned char *idat_buf = (unsigned char *)malloc(idat_content_len);
    size_t p = 0; // idat_buf への書き込み位置
    size_t raw_ptr = 0; // filtered_data の読み込み位置
    // zlib Header
    idat_buf[p++] = 0x78;
    idat_buf[p++] = 0x01;

書き込みの順番は

  1. BFINAL(このブロックが最後かどうか.1で最後,0ならまだ続く)
  2. サイズ
  3. サイズの反転
  4. フィルタ済み画像データ

わざわざサイズの反転も書き込まなきゃいけないのは面倒だけど,多分安全のためなんだろう.あと他はビッグエンディアン(反転)なのに,ここだけリトルエンディアンで書かなきゃいけない.まじでここだけ.一体なぜ
Geminiいわく

これはPNGの仕様ではなく,内部に含まれる Deflate (RFC 1951) の仕様 がリトルエンディアンを採用しているためです。

データバッファの末尾にAdlerを書き込むのも忘れてはいけない.

分割されたブロックの書き込みサンプルコード
    for (size_t i = 0; i < num_blocks; i++) {
        // 今回のブロックに入れるデータサイズを計算
        size_t remaining = filtered_data_size - raw_ptr;
        size_t chunk_size = (remaining > MAX_BLOCK_SIZE) ? MAX_BLOCK_SIZE : remaining;
        // BFINAL判定: これが最後のブロックなら1,そうでなければ0
        unsigned char bfinal = (raw_ptr + chunk_size == filtered_data_size) ? 0x01 : 0x00;
        // Block Header // BFINAL(1bit) + BTYPE(2bit=00) 
        // bfinalが1なら 0x01, 0なら 0x00
        idat_buf[p++] = bfinal;
        // LEN (リトルエンディアンで書き込む)
        uint16_t len = (uint16_t)chunk_size;
        idat_buf[p++] = len & 0xFF;        // 下位バイト
        idat_buf[p++] = (len >> 8) & 0xFF; // 上位バイト

        // NLEN (1の補数,これもリトルエンディアン)
        uint16_t nlen = ~len;
        idat_buf[p++] = nlen & 0xFF;
        idat_buf[p++] = (nlen >> 8) & 0xFF;
        // Data Copy
        for (size_t j = 0; j < chunk_size; j++) {
            idat_buf[p++] = filtered_data[raw_ptr++];
        }
    }
    uint32_t adler = calc_adler32(filtered_data, filtered_data_size);
    uint32_t adler_be = swap_uint32(adler);
    memcpy(idat_buf + p, &adler_be, 4);
    p += 4;

これでようやくIDATのデータ部分が完成する.
IHDRと同様,データの長さ,タイプ名,データ,CRCの順番で書き込む

idat書き込みサンプルコード
    // Length
    uint32_t chunk_len_be = swap_uint32((uint32_t)idat_content_len);
    fwrite(&chunk_len_be, 4, 1, fp);

    // Type
    const unsigned char idat_type[] = "IDAT";
    fwrite(idat_type, 1, 4, fp);

    // Data
    fwrite(idat_buf, 1, idat_content_len, fp);

    // CRC (Type + Data)
    unsigned char *crc_check_buf = (unsigned char *)malloc(4 + idat_content_len);
    if(crc_check_buf) {
        for(int i=0; i<4; i++) crc_check_buf[i] = idat_type[i];
        for(size_t i=0; i<idat_content_len; i++) crc_check_buf[4+i] = idat_buf[i];

        uint32_t crc_idat = calc_crc32(crc_check_buf, 4 + idat_content_len);
        uint32_t crc_idat_be = swap_uint32(crc_idat);
        fwrite(&crc_idat_be, 4, 1, fp);
        free(crc_check_buf);
    } else {
            fprintf(stderr, "Memory allocation for CRC check failed.\n");
    }

    free(idat_buf);

1.5 IEND

終りを示すだけなのでやることは少ない.
今までと同様,データの長さ,タイプ名,データ,CRCの順番で書き込むのだが,そもそもデータサイズが0なので,データの長さは0でいいし,CRCもタイプ名だけでいい

iendサンプルコード
void png_IEND(FILE* fp){
    // Length (0)
    uint32_t len = 0;
    fwrite(&len, 4, 1, fp); // 0はバイト反転しても0なのでそのまっま
    // Type
    const unsigned char type[] = "IEND";
    fwrite(type, 1, 4, fp);
    // CRC
    uint32_t crc = calc_crc32(type, 4);
    uint32_t crc_be = swap_uint32(crc);
    fwrite(&crc_be, 4, 1, fp);
}

1.6 補足

テキストチャンクとかガンマチャンクとか,他にも付け加えられるやつはあるが,なくても良い
IHDR,IDAT,IENDの3つで基本成り立つ.
ちなみにテキストチャンクは通常バージョンと日本語対応バージョンがある.
後者だけ作った.

itxt_chunk
/* * iTXtチャンクを書き込む関数
 * keyword: Latin-1 (ASCII) 文字列 (例: "Title", "Author")
 * text:    UTF-8 文字列 (日本語OK)
 */
void png_itxt_chunk(FILE *fp, const char *keyword, const char *text) {
    if (!text || !keyword) return; // データが無ければ何もしない
    size_t kw_len = strlen(keyword);
    size_t text_len = strlen(text);
    size_t data_len = kw_len + 1 + 1 + 1 + 1 + 1 + text_len;
    // iTXtのデータ構造:
    // Keyword + Null + CompFlag + CompMethod + LangTag + Null + TransKey + Null + Text
    // 簡易実装のため,LangTagとTransKeyは空(Nullのみ)
    // データ長の計算
    // kw_len
    // + 1 (Null separator)
    // + 1 (Compression flag: 0)
    // + 1 (Compression method: 0)
    // + 1 (Lang tag separator: 空文字の終端)
    // + 1 (Trans keyword separator: 空文字の終端)
    // + text_len
    // バッファ作成
    unsigned char *buf = (unsigned char *)malloc(data_len);
    size_t p = 0;
    // 1. Keyword
    memcpy(buf + p, keyword, kw_len);
    p += kw_len;
    buf[p++] = 0x00; // Null separator
    // 2. Flags (非圧縮)
    buf[p++] = 0; // Compression flag (0 = Uncompressed)
    buf[p++] = 0; // Compression method (0)
    // 3. Language Tag (空)
    buf[p++] = 0x00; // Null separator only
    // 4. Translated Keyword (空)
    buf[p++] = 0x00; // Null separator only
    // 5. Text (UTF-8)
    memcpy(buf + p, text, text_len);
    p += text_len;
    {// --- ファイル書き込み ---
        uint32_t len_be = swap_uint32((uint32_t)data_len);// Length
        fwrite(&len_be, 4, 1, fp);

        // Type ("iTXt")
        fwrite("iTXt", 1, 4, fp);

        // Data
        fwrite(buf, 1, data_len, fp);

        // CRC (Type + Data)
        unsigned char *crc_buf = (unsigned char *)malloc(4 + data_len);
        memcpy(crc_buf, "iTXt", 4);
        memcpy(crc_buf + 4, buf, data_len);
        
        uint32_t crc = swap_uint32(calc_crc32(crc_buf, 4 + data_len));
        fwrite(&crc, 4, 1, fp);
        free(crc_buf);
    }
    free(buf);
}

2. APNG

追加で必要なチャンクがある.acTL(アニメーション全体を管理する設定)とfcTL(フレームごとの設定),そしてfdAT(APNG用の画像データチャンク)
設定関係のチャンクは一旦いいとして,このfdATというのはIDATとほぼ何も変わらない.違いはデータの先頭にフレームナンバーが入ることだけである.
そして一番面倒な点として,apngのチャンク構成は以下のようになる

  1. IHDR
  2. acTL :全体のアニメーション設定
  3. fcTL(seq=0) :1フレーム目のアニメーション設定
  4. IDAT :1フレーム目の画像データ
  5. fcTL(seq=1) :2フレーム目のアニメーション設定
  6. fdAT(seq=2) :2フレーム目の画像データ
    ...(fcTLとfdATが交互に続く)
  7. IEND
    1フレームごとにシーケンス番号が振り分けられるのだが,最初のフレームだけはIDATチャンクが入り,シーケンス番号も入らない.これはapngに対応していないビューワーが,IDATを読み込んで1フレーム目だけを読み込み,それ以外のapng用のチャンクを無視するため.

2.1 acTL

全体の設定と言っても,「総フレーム数」と「ループする回数」を書き込むだけ
ちなみにループの回数を0にすると無限ループとなる.

acTLサンプルコード
void png_acTL(uint32_t num_frames, uint32_t num_plays, FILE *fp) {
    unsigned char buf[8];
    uint32_t frames_be = swap_uint32(num_frames);
    uint32_t plays_be  = swap_uint32(num_plays); // 0なら無限ループ
    memcpy(buf,     &frames_be, 4);
    memcpy(buf + 4, &plays_be,  4);
    // 書き込み処理 (Length, Type, Data, CRC)
    uint32_t len = swap_uint32(8);
    fwrite(&len, 4, 1, fp);
    fwrite("acTL", 1, 4, fp);
    fwrite(buf, 1, 8, fp);
    // CRC
    unsigned char crc_buf[12];
    memcpy(crc_buf, "acTL", 4);
    memcpy(crc_buf + 4, buf, 8);
    uint32_t crc = swap_uint32(calc_crc32(crc_buf, 12));
    fwrite(&crc, 4, 1, fp);
}

2.1 fcTL

各フレームごとの設定なのだが,結構項目が多い.下2つは初めて知った

  • x_offset, y_offset :描画の位置のオフセット
    部分的に更新されるアニメーション,スプライトの一部だけ差し替える,無駄なビットマップ転送を減らすなどができる
  • delay_num, delay_den :フレームが表示される時間 = delay_num / delay_den 秒
    numが1,denが30なら30fpsになる.
  • dispose_op :フレーム消去のオプション.
    NONE,BACKGROUND,PREVIOUSの3つがある
    • NONE:フレームをひたすら合成する.残像みたいになる.
    • BACKGROUND:フレーム領域だけ透明に戻す.
    • PREVIOUS:フレームを描く前の状態に巻き戻す.1フレームだけエフェクト入れてすぐ戻すとか?
  • blend_op :アルファを無視して上書きするか,透明度を使った通常のアルファブレンド合成するか

特殊ルールとして,delay_denが 0 の場合は 100 とみなす仕様がある.

fcTLサンプルコード
void png_fcTL(uint32_t seq_num, uint32_t width, uint32_t height,
        uint32_t x_offset, uint32_t y_offset,
        uint16_t delay_num, uint16_t delay_den,
        uint8_t dispose_op, uint8_t blend_op,
        FILE *fp) {
    uint32_t seq_be = swap_uint32(seq_num);
    uint32_t w_be   = swap_uint32(width);
    uint32_t h_be   = swap_uint32(height);
    uint32_t x_off  = swap_uint32(x_offset);
    uint32_t y_off  = swap_uint32(y_offset);
    uint16_t d_num  = swap_uint16(delay_num);
    uint16_t d_den  = swap_uint16(delay_den);
    // データバッファへ書き込み
    unsigned char buf[26];
    size_t p = 0;
    memcpy(buf + p, &seq_be, 4); p+=4;
    memcpy(buf + p, &w_be,   4); p+=4;
    memcpy(buf + p, &h_be,   4); p+=4;
    memcpy(buf + p, &x_off,  4); p+=4;
    memcpy(buf + p, &y_off,  4); p+=4;
    memcpy(buf + p, &d_num,  2); p+=2;
    memcpy(buf + p, &d_den,  2); p+=2;
    buf[p++] = dispose_op;
    buf[p++] = blend_op;
    // 書き込み
    uint32_t len = swap_uint32(26);
    fwrite(&len, 4, 1, fp);
    fwrite("fcTL", 1, 4, fp);
    fwrite(buf, 1, 26, fp);
    // CRC
    unsigned char *crc_check = (unsigned char *)malloc(4 + 26);
    memcpy(crc_check, "fcTL", 4);
    memcpy(crc_check + 4, buf, 26);
    uint32_t crc = swap_uint32(calc_crc32(crc_check, 30));
    fwrite(&crc, 4, 1, fp);
    free(crc_check);
}

2.2 fdAT

まじでIDATと変わらない.データバッファの最初にシーケンス番号を入れるのと,タイプ名が"fdAT"になるだけ
実装するときはブロック分割とか共通化してしまっていい.

fdATサンプルコード(一部)
void png_fdAT(uint32_t seq_num, const unsigned char *filtered_data, uint32_t width, uint32_t height, FILE *fp){
    /* ---画像にフィルタかける--- */
    
    // IDATチャンクの中身の総サイズ
    // seq(4) + zlibヘッダ(2) + (ブロックヘッダ(5) * ブロック数) + 生データ総量 + Adler32(4)
    size_t idat_content_len = 4 + 2 + (num_blocks * 5) + filtered_data_size + 4;

    unsigned char *idat_buf = (unsigned char *)malloc(idat_content_len);
    size_t p = 0; // idat_buf への書き込み位置
    size_t raw_ptr = 0; // filtered_data の読み込み位置

    //バッファの先頭に Sequence Number をセット
    uint32_t seq_be = swap_uint32(seq_num);
    memcpy(idat_buf, &seq_be, 4);
    p += 4; // seqの分進める

    // ---分割データ書き込み---
       ---Adler-32計算とバッファに書き込み--- */
    
    /* ---Lengthの書き込み--- */
    
    // Type,ここは名前変えとく
    const unsigned char idat_type[] = "fdAT";
    fwrite(idat_type, 1, 4, fp);
    
    /* ---Type. Data, CRCの順番でファイルに書き込み--- */
}

3. おわり

これで非圧縮のpng/apngができる
車輪の再開発でしか無いが,png/apngの仕様を深く知ることができたし,いい経験になった気がする
あと,実際に作成した画像と,それをwebにあげてから再ダウンロードした画像のサイズを比べたら,192.4KB → 136.5KBとなっていた.圧縮にも興味はあるが,ハフマン木とか面倒そう
ビューワーを作ろうとしたらもっとグロそう.
interlace methodなんかはデータ配列から変わるらしいし
先人は偉大だ

生成したpng

apng(zennでは動かないかも)

コード
https://github.com/Totan256/myPNG

Discussion