Canvas画像処理

【JavaScript】 自力でCanvas画像をBMPに変換する

更新日:2023/04/14

Canvas画像をBMPに変換したかったのだけど、Chromeが対応していなかった。
仕方がないので、自力でBMPファイルを生成してみます。

2023/4 設定ミスによりデモページへのリンクが無効になっていたので修正しました。

 

BMPのファイルフォーマット

自力でBMPを作成するのに必要なのは、BMPのファイルフォーマットを知ること。
そこで、調査してみました。

調べた結果は、次の記事で紹介しているので読んでみてください。(丸投げ)

BMPは複数の情報ヘッダーが定義されていて、その中の一つを選択します。
明確な根拠はないですが、 おそろく一番多く使用されていると思われる BITMAPINFOHEADER を、今回は使用します。

 

4つのパターンで生成

今回は、次の4つのパターンでコードを作成してみます。
各パターンごとに、デモとソースコードを公開しているので参考にしてみてください。

  1. 画像データをRGBの3バイトで生成

    1ピクセルを3バイトで生成します。
    そのため生成されたBMPファイルは、縦×横×3にヘッダー部を足したバイト数になります。
    全く圧縮されていない状態なので、PNGやJPEG等と比較すると非常にサイズが大きくなります。

    ですがBMPを生成するアプリの多くは、この形式で出力しています。
    現在はBMPをほとんど使っていないので、これで十分なのかもしれません。

  2. パレットを生成

    画像に使用している色数が256以下のときはパレットを生成します。
    そして画像データ部は、1ピクセルをパレットのインデックス番号で表します。

    インデックス番号は1バイトなので、画像データ部分は縦×横バイトになります。

    ただし、パレット部は一つの色が 赤・緑・青・予備 の4バイトで構成されます。
    そのため元となる画像によっては、①の画像データをRGBの3バイトで生成したときよりも全体のサイズが大きくなる可能性があります。
    今回は最終的なバイト数を比較して、パレットを生成するかどうかを判断しています。

    なお、インデックス番号は1バイトと書きましたが、色数が2色のときは1ビットで、16色以下のときは4ビットです。
    その分ファイルサイズが、小さくなります。
    しかしパレットは、4バイトのままです。

    この方式は、パレット生成のために画像データの色を一色毎に比較しながら抜き出します。
    ①よりも動作が遅くなるのが欠点です。

  3. RLE(ランレングス)圧縮を行う

    画像データ部が8ビット(1バイト)または4ビットのとき、画像データ部をRLE圧縮します。
    圧縮後のデータを格納するバッファとしてArrayBufferを使っているのですが、これは最初に確保するバッファサイズを指定する必要があります。
    そのため、サイズを計測するためだけに圧縮作業を行います。
    そして計算したサイズでバッファを確保した後、再度圧縮作業をします。

    つまり、2回も圧縮処理をおこないます。
    その上、この処理の前段階としてパレット抽出もおこなっているので、動作がとても遅いです。

    なお、RLE処理されたBMPファイルに対応していない(読み込めない)アプリもあります。
    ファイルサイズが小さくなるのは大きなメリットですが、デメリットも多いので実装するかどうか悩みどころですね。

  4. PNG/JPEGでデータ部を生成

    画像データ部を、PNGまたはJPEGデータで生成します。
    生成されたBMPファイルはとても小さくなります。
    とはいえ、中身がPNGまたはJPEGなのに、これはBMPなんだろうかという疑問が残ったりします。
    そのまま使えばいいじゃーん、とも思います。

    それに、この形式に対応していないアプリはとても多い印象です。
    読み込もうとすると、エラーになってしまうのです。

    BMP化しないでPNGやJPEGのまま使用するほうが望ましいですね。

    今回は実験的な意味でやってみます。

 

各パターン共通コード

各パターン共通のコードです。

HTML

まずはHTML部です。

HTML


<style>
    #inch{
        width: 1in;
        height: 1in;
    }
</style>

<div id="inch"></div>
<p><input type="file" id="file_select"></p>
<p><canvas id="canvas"></canvas></p>
<p><button id="button">bmp生成</button></p>

BMPファイルは、1メートルのピクセル数が記録されています。
その値を計測するために、<div id="inch"></div>を、設置してあります。
これで、1インチ単位でのピクセル数を計測できるので、メートルに変換します。

詳しくはこちらを読んでみてください。
【JavaScript】 ブラウザのDPIを計測する

あとは、ファイル選択用のinputタグと、読み込んだ画像ファイルを表示するCanvasタグ、BMPファイル生成を開始するbuttonが設置されています。

DOM構築待ち

DOMContentLoadedイベントを登録して、DOMが構築されるのを待ちます。

JavaScript


window.addEventListener( "DOMContentLoaded" , ()=>{
    // dpi取得
    const dpi = document.getElementById("inch").offsetHeight;
    start("file_select","canvas","button",dpi)
});

イベントリスナーでは、DPI( 1インチのドット数 )を取得しています。

UIの処理

HTMLで配置したタグを操作したときの処理です。

JavaScript

const start = (fileSelectId,canvasId,buttonId,dpm) =>{

    const context = document.getElementById(canvasId).getContext("2d");

    const image = new Image();
    image.onload = ()=>{
        context.canvas.width = image.width;
        context.canvas.height = image.height;
        context.drawImage(image, 0, 0, context.canvas.width, context.canvas.height);
    };
    image.onerror = ()=>alert("読み込めませんでした");

        // FileReaderの生成
    const reader = new FileReader();
    reader.onload = () => image.src = reader.result

        // File APIで画像ファイルを選択
    document.getElementById(fileSelectId)
        .addEventListener("change",function(){
            reader.readAsDataURL(this.files[0]);
        });

    document.getElementById(buttonId)
        .addEventListener("click",()=>bitmapDownload(context,dpm));
};

File APIで画像ファイルが選択されたら、FileReaderでファイルを読み込みデータURLに変換します。
この時点では、画像データは圧縮されたバイナリデータです。

次にImageのsrc属性にデータURLをセットして、画像を展開します。

最後にCanvasに展開された画像を貼り付けています。

BMPファイルのダウンロード

BMPファイルを生成する関数を呼び出し、生成されたデータをダウンロードしています。

JavaScript

    // ビットマップファイルダウンロード
const bitmapDownload = (context,dpi) =>{
    const {width,height} = context.canvas;
    const imageData = context.getImageData(0,0,width,height).data;

    imageDataToBmp( imageData , width , height ,dpi )
        .then( buffers => {
            const blob = new Blob( buffers, { type:  "octet/stream" });
            const link = document.createElement("a");
            link.href = URL.createObjectURL(blob);
            link.download = "image.bmp";
            link.click();

            URL.revokeObjectURL(link.href);
            link.remove();
        });
};

imageDataToBmp()は、RGBAの4バイトを1ピクセルとする配列形式のデータからBMPファイルのバイナリイメージを生成しています。
この関数はPromiseを返しますが、別スレッドで変換するなどの処理はおこなっていません。

thenで受け取る値は、BMPファイルの各パーツを順番に並べたArrayBufferの配列です。
次のようなイメージです。
[ [ファイルヘッダーのバッファ] , [情報ヘッダーのバッファ], [パレットのバッファ] .... ]

これを元にBlobを生成すると、一つのバイナリデータとしてまとめてくれます。

あとはオブジェクトURLを生成して、aタグ経由でダウンロードしています。

RGBAをRGBに変換

今回は情報ヘッダーとしてBITMAPINFOHEADERを使用するので、赤・青・緑の3つの成分のみ使用できます。
元の画像に透明度が適用されていても、そのまま反映することができません。

そこで、透明度を考慮してRGBAをRGBに変換します。

JavaScript


   // RGBAをRGBに変換(透明度考慮)
const  RGBAtoRGB = ( r , g , b , a ) =>{
    if( a === 0 ) return [255,255,255];

    const opacity = a / 255;
    const cb = Math.round(b * opacity + 255 * ( 1 - opacity));
    const cg = Math.round(g * opacity + 255 * ( 1 - opacity));
    const cr = Math.round(r * opacity + 255 * ( 1 - opacity));
    return  [cr,cg,cb];
};

4バイト境界への対応

BMPファイルの画像データは、1行(横方向)のバイト数を4の倍数に合わせる必要があります。

次のコードは、1行の実質ビット数から4の倍数に合わせたバイト数を計算しています。

JavaScript


// 入力されたビット数を4バイトに合わせる
const widthAdjustBytes = widthBits =>
    Math.ceil(Math.ceil(widthBits/8)/4) * 4;

 

パターン1:画像データをRGBの3バイトで生成

まずは一番オーソドックスな、画像データをRGBの3バイト表したBMPファイルの生成です。

ここではソースを細切れで紹介しています。
完全なソースは、デモと一緒に次のページで公開しているので、そちらを見てくださいね!

補足:

このソースは以降のパターンで追加するソース量を減らすために、できるだけ共通部分を増やすように調整しています。
そのため、機能を実現するだけならもっとシンプルな構造で十分だったりします。
シンプルな方が速度も速いですしね。

まずはメインとなるimageDataToBmp()関数です。
この関数は、以降のパターンでも共通で使用します。

なので、パレットデータが必要ないのに、取得していたりします。

   // BMPバイナリイメージの生成
const imageDataToBmp = (imageData,width,height,dpi=96) =>{
    const dpm = Math.ceil((dpi === 0 ? 96 : dpi) * 39.3701 );

    const imgMgr = new imgManager( imageData,width,height );
        // データ部生成
    const bitmapData = getBitmapData(imgMgr);
        // パレットデータ取得
    const paletteData = imgMgr.palette;
        // BITMAPFILEHEADERのセット
    const dataOffset = paletteData.buffer.byteLength +  14 + 40;
    const fileSize = bitmapData.buffer.byteLength + dataOffset;

    const fileHeader =  getHeader( [U8,U8,U32,U16,U16,U32]
        ,["B".charCodeAt(0) , "M".charCodeAt(0) , fileSize , 0 , 0 ,dataOffset]);

        // BITMAPINFOHEADERのセット
    const infoHeader =  getHeader( [U32,U32,I32,U16,U16,U32,U32,U32,U32,U32,U32]
        ,[40 , imgMgr.width , imgMgr.height  , 1 , imgMgr.bitPerPixel
            ,imgMgr.compression , 0 , dpm , dpm , paletteData.length , paletteData.length]);

    const buffers = [fileHeader.buffer,infoHeader.buffer,paletteData.buffer,bitmapData.buffer];
    return new Promise( resolve => resolve( buffers) );

};

この関数は、BMPファイルを構成するファイルヘッダーや情報ヘッダーなどの各パーツ毎にArrayBufferを生成して、Promiseの結果として渡しています。
パーツを生成する順番はBMPファイルでの順番と異なりますが、Promiseに渡すときに並び替えているので問題ありません。

次は、UI部から受け取ったイメージデータを管理するクラスです。

    // imageData の管理
const imgManager = class {
    #srcData; #width; #height;

    constructor( imageData,width,height ) {
        this.#width=width;this.#height=height;
        this.#srcData = imageData;
    }
    get compression(){ return 0;}
    get width(){ return this.#width;}
    get height(){ return this.#height;}
    get bitPerPixel(){ return 24;}
    imageConvert( setter ){ // データの列挙
        const width = this.#width,height = this.#height,src = this.#srcData;
        const lineBytes = width * 4;
            // ボトムアップでピクセルを取得
        for( let col = height -1 ; col >= 0 ; col -- ){
            for( let row = 0 , pos = col * lineBytes ; row < width ; row ++, pos += 4 )
                if( setter.setData( RGBAtoRGB(src[pos] , src[pos+1],src[pos+2],src[pos+3] ) ) === false)
                    break;
            if( col !== 0 ) setter.lineEnd();
        }
        setter.end( );
        return setter.result;
    }
    get palette(){ // パレット情報を返す
        const buffer = [];buffer.byteLength = 0;
        return {buffer:buffer,length:0}; // TypedArrayを偽装
    }
};

imageConvertメソッドは、imageDataからピクセルをボトムアップで取得して、引数で受け取ったオブジェクトに渡しています。
引数で受け取ったオブジェクトは、次の3つのメソッドを持っています。

setData: ピクセルデータを受け取って処理するメソッド
lineEnd: 行末まで処理したことを通知するメソッド
end: 全てのピクセルを処理したことを通知するメソッド

また、このクラスはパレットデータを返していますが、今回はパレットデータを必要としないので空の配列を返しています。

次は、imageConvertメソッドに渡すオブジェクトの定義です。

    // バッファーセットクラス
const SetBuffer = class {
    #buffer; #pos=0;
    constructor(size) {this.#buffer = new Uint8Array( size );}
    setData(value){this.#buffer[this.#pos++] = value;}
    get result(){ return this.#buffer;}
}
const Set8 =  class extends SetBuffer{ // 1バイト毎
    #outCount=0;
    constructor(size) {super(size);}
    setData(value) {
        this.#outCount++;
        return super.setData(value);
    }
    lineEnd(){
        const mod = this.#outCount % 4;
        if( mod > 0 ) for( let i = 4 - mod; i > 0 ; i -- )
            super.setData(0);
        this.#outCount = 0;
    }
    end(){this.lineEnd();}
};
const Set24 = class  extends Set8{ // 3バイト毎
    constructor(size) {super(size);}
    setData(rgb){
        super.setData(rgb[2]);super.setData(rgb[1]);super.setData(rgb[0]);
    }
};

SetBufferクラスは、ArrayBufferの初期化とArrayBufferへのデータセットを管理しています。
Set8クラスは、バイト数の管理とSetBufferクラスへのデータ渡しをおこなっています。
Set24クラスは、3バイトのデータを受け取って、Set8クラスに渡しています。

次の関数は、imageConvertメソッドを実際に呼び出しています。

    // データ部生成
const getBitmapData = (imgMgr) =>{
    const size = widthAdjustBytes(imgMgr.width * imgMgr.bitPerPixel)
        * imgMgr.height;
    const setter = new Set24( size );
    return imgMgr.imageConvert( setter );
};

最後は、ファイルヘッダーと情報ヘッダー向けのデータセット関数です。

    // ヘッダー生成用フォーマット定数
const [U8,U16,U32,I32] = (()=>{
    const dp = DataView.prototype;
    return [[dp.setUint8,1],[dp.setUint16,2],[dp.setUint32,4],[dp.setInt32,4]];
})();
    // ヘッダー生成
const getHeader = (format,values)=> {
    const size = format.reduce((a,b)=>a+b[1],0);
    const buffer = new DataView(new ArrayBuffer( size ));
    values.reduce((a,b,index)=>{
        format[index][0].call(buffer,a,b,true);
        return a + format[index][1];
    },0);
    return buffer;
};

引数のformatで受け取った情報(フォーマット定数の配列)を元に、バッファーサイズ計算してバッファーを確保します。
次にvaluesで受け取った値を、formatに従ってバッファーにセットしています。

 

パターン2:パレットを生成

二つ目のパターンは、パレットを生成してファイルサイズを減らします。

ここではソースの一部を紹介しています。
完全なソースは、デモと一緒に次のページで公開しているので、そちらを見てくださいね!

ここでは主要な部分だけ解説します。

次の関数は、イメージデータからパレットを抽出しています。

 // imageData.data(DataView) からパレットを抽出
const extractPalette = imageData =>{
    const dataLength =  imageData.byteLength;
    const paletteBuffer = new Uint32Array(256); // パレット用バッファー

    let indexStart = 256;
    for( let i = 0 ; i < dataLength ; i += 4 ){
        const color = imageData.getUint32( i ); // ピクセルRGBA(32bit)をBEで取得
        if( paletteBuffer.indexOf(color,indexStart) === -1){ // indexStartから最後まで検索
            if( indexStart <= 0) { // 色が256以上ある パレット無効
                return null;
            }
            indexStart --;
            paletteBuffer[indexStart]=color;
        }
    }
    if( indexStart === 256 ) return null;
    // paletteBufferから使用している部分を切り出し
    return indexStart === 0 ? paletteBuffer : paletteBuffer.slice(indexStart);
};

Canvasから取得できるイメージデータは1バイト配列ですが、この関数はDataViewに変換したものを引数で受け取っています。
DataViewにすることで、RGBAを4バイトの数値で扱うことができます。

あとは重複を確認しながら、配列に取得した数値をセットするだけです。

ただし、今回は配列ではなくてUint32Arrayを使用しています。
Uint32ArrayのindexOf()は、引数で開始位置を指定できますが、終了位置を指定できません。
終端までの値をチェックします。
値を先頭からセットすると未使用の領域までチェックすることになります。
そこで、値を配列の末尾からセットしています。

最後に、使用してる部分だけ抜き出して返しています。

次の関数はパレットを抽出した後、パレットを使用するかどうかを判定しています。

// パレットを有効にするかの判定
const judgementPaletteSize = (palette,imgManager)=>{

    if( palette === null ) return null;
    const {width,height} = imgManager;

    const length = palette.length;
    const bitPerPixel = imgManager.lengthToBitPerPixel(palette);
        // パレット未使用時サイズ
    const rgbFileSize = widthAdjustBytes(width * 24) * height;
        // パレット使用時のパレット+ビットマップデータバイト数
    const paletteFileSize = widthAdjustBytes(width * bitPerPixel)
            * height + length * 4;
        // パレット出力とRGB出力のサイズを比較
    return ( paletteFileSize > rgbFileSize ) ? null : palette;

};

抽出したパレットの数から出力後のデータサイズと、パレットを使用しないときのサイズを計算して比較します。

次の関数は抽出したパレットを、BMPファイル上での形式に変換しています。

    // パレットをビットマップファイルでの形式に変換(RGBA=>BGR)
const convertPalette = (palette)=>{ // paletteはUint32Array
        // Uint32ArrayはLEなので、メモリ上にRGBAはABGRでセットされている
        // DataViewで1バイトずつ処理する
    const dView = new DataView( palette.buffer );
    for( let i = 0 ; i < dView.byteLength ; i+=4 ){
        const rgb = RGBAtoRGB( dView.getUint8(i+3) , dView.getUint8(i+2)
            , dView.getUint8(i+1) , dView.getUint8(i)  );
        dView.setUint8(i,rgb[2]);dView.setUint8(i+1,rgb[1]);
        dView.setUint8(i+2,rgb[0]);dView.setUint8(i+3,0);
    }
    return palette;
};

BEとかLEとかめんどうなことになっています。

それ何?という方はこちら見てね!
エンディアンとは何か調べてみた

パレットのBMPファイル上での形式は、BGR0です。
変換前の形式はABGRです。

RGBAからRGBへの変換をおこないながら、パズルを解くように値を入れ替えていきます。

次は、パターン1でも使用していたimageConvert()メソッドです。

// imageData の管理
const imgManager = class {
    #srcData; #width; #height;  #palette; #isConverted = false;

    constructor( imageData,width,height ) {
        this.#width=width;this.#height=height;
        this.#srcData = new DataView(imageData.buffer);
            // パレット抽出後、パレット有効判定
        this.#palette = judgementPaletteSize(extractPalette(this.#srcData),this);
    }
      …省略
    imageConvert( setter ){ // データの列挙
        const width = this.#width,height = this.#height,src = this.#srcData;
        const palette = this.#palette;
        const lineBytes = width * 4;
        const func = palette === null
            ? (pos)=>RGBAtoRGB(src.getUint8(pos),src.getUint8(pos+1)
                ,src.getUint8(pos+2),src.getUint8(pos+3))
            : (pos)=>palette.indexOf(src.getUint32( pos ));

        // ボトムアップでピクセルを取得
        for( let col = height -1 ; col >= 0 ; col -- ){
            for( let row = 0 , pos = col * lineBytes ; row < width ; row ++, pos += 4 )
                if( setter.setData( func(pos ) ) === false) break;
            if( col !== 0 ) setter.lineEnd();
        }
        setter.end( );
        return setter.result;
    }
      …省略
};

パターン1と異なるのは、setter.setData()に渡す値です。
パレットを使用するときはパレットインデックスを、使用しないときはRGB値を渡しています。

次は、imageConvertメソッドに渡すオブジェクトの定義です。
パターン1にもあったものを、パレットインデックスの出力に対応させています。

// バッファーセットクラス
const SetBuffer = class{
    #buffer; #pos=0;
    constructor(size) {this.#buffer = new Uint8Array( size );}
    setData(value){this.#buffer[this.#pos++] = value;}
    get result(){ return this.#buffer;}
}
const Set8 =  class extends SetBuffer{ // 1バイト毎
    #outCount=0;
    constructor(size) {super(size);}
    setData(value) {
        this.#outCount++;
        return super.setData(value);
    }
    lineEnd(){
        const mod = this.#outCount % 4;
        if( mod > 0 ) for( let i = 4 - mod; i > 0 ; i -- )
                super.setData(0);
        this.#outCount = 0;
    }
    end(){this.lineEnd();}
};
const Set4 =  class extends Set8 {  // 4bit毎
    #highBit4 = -1;
    constructor(size) {super(size);}
    setData(value){
        if( this.#highBit4 === -1 ) { this.#highBit4 = value << 4;}
        else{super.setData(this.#highBit4 | value); this.#highBit4=-1;}
    }
    lineEnd(){
        if( this.#highBit4 !== -1 ) this.setData(0);
        super.lineEnd();
    }
};
const Set1 =class extends Set8{  // 1bit毎
    #byte =0; #count=0;
    constructor(size) {super(size);}
    setData(value){
        const byte = this.#byte | value;
        this.#count ++;
        if( this.#count === 8 ) {
            super.setData(byte);
            this.#byte = this.#count = 0;return;
        }
        this.#byte = byte << 1;
    }
    lineEnd(){
        if( this.#count !== 0 ) {
            super.setData( this.#byte << (7 - this.#count) );
        }
        super.lineEnd();
        this.#byte = this.#count = 0;
    }
};
const Set24 =  class  extends Set8{ // 3バイト毎
    constructor(size) {super(size);}
    setData(rgb){
        super.setData(rgb[2]);super.setData(rgb[1]);super.setData(rgb[0]);
    }
};

BMPのデータ部生成も、少し変更しています。

 // ビットマップデータ部生成
const getBitmapData = imgMgr =>{

    const size = widthAdjustBytes(imgMgr.width * imgMgr.bitPerPixel)
        * imgMgr.height;

    // データセットクラスをセレクト
    const setter = (()=>{
        switch (imgMgr.bitPerPixel ){
            case 24:return  new Set24(size);
            case 8:return  new Set8(size);
            case 4:return  new Set4(size);
            case 1:return  new Set1(size);
        }
    })();
        // データ出力
    return imgMgr.imageConvert( setter );
};

 

パターン3::RLE(ランレングス)圧縮を行う

三つ目のパターンは、RLE(ランレングス)圧縮を行ってファイルサイズをさらに減らします。

ここではソースの一部を紹介しています。
完全なソースは、デモと一緒に次のページで公開しているので、そちらを見てくださいね!

次の関数は、RLE圧縮をおこなっています。

// rleエンコード size 正:確保するバイト数 負:計測モード 最大バイト数
const rleEncode = (imgManager,size)=>{
    const bitPerPixel = imgManager.bitPerPixel;
    if( bitPerPixel !== 4 && bitPerPixel !== 8 )
        return size < 0 ? 0 : [];

    const base = size < 0 ? OutputCounter : SetBuffer;
    const setter = new (( bitPerPixel === 4 ? SetRle4 : SetRle8 )(base))( Math.abs(size));
    imgManager.imageConvert(setter);
    return setter.result;
}

2番目の引数がマイナスのときは、データを生成せずに圧縮したときのバイト数を計測します。
プラスのときは、その値でバッファを確保してデータを生成します。

実際に圧縮をおこなっているのは、次のクラス群です。

const OutputCounter = class{ // 出力バイト数カウント
    #count=0; #maxSize;
    constructor(size) { this.#maxSize=size;}
    stop(){}
    setData( v ){
        this.#count ++;
        if( this.#count >= this.#maxSize ) this.stop();
    }
    get result(){ // #maxSize 以上なら 0 を返す
        return this.#count < this.#maxSize ? this.#count : 0;
    }
}
const Discontinuity = class { // RLE不連続データバッファ
    #buffer = new Uint8Array(255);
    #pos = 0;
    get length(){ return this.#pos;}
    set length(len){ this.#pos=len;}
    add(value){ this.#buffer[this.#pos++] = value;}
    forEach( func ){
        const pos = this.#pos,buf = this.#buffer;
        for(let i = 0 ; i < pos ; i++) func( buf[i] );
    }
};
const SetRle = base => class extends base { // RLEメイン処理
    // 中断制御 baseから呼び出す
    #stopFlg = false; stop(){ this.#stopFlg = true;}

    #discon = new Discontinuity; // 不連続用バッファ

    #sameMax; // 最大連続回数
    constructor( size,sameMax=255 ) {
        super(size); this.#sameMax = sameMax;
    }

    setByte(byte){super.setData( byte )} // 子クラスからbaseへデータを渡す
    // 絶対モード時の2バイト境界制御
    #wordByteCount = 0;
    wordStart(){ this.#wordByteCount=0;}
    wordValue(byte){this.#wordByteCount ++;super.setData( byte );}
    wordEnd(){ if( this.#wordByteCount % 2 === 1 ) super.setData( 0 ); }

    // 連続データ数
    #sameCount = 1;
    setData(value) {

        if( !this.initialCheck(value) ) return true;
        const beforeByte = this.beforeByte;

        const sameCount = this.#sameCount;
        const disconCount = this.#discon.length;

        if(  beforeByte === value && sameCount < this.#sameMax ) { // 連続は255回まで(4bitは254まで)
            if( disconCount > 0 )
               this.#discontinuityRelease(this.#discon);
            this.#sameCount ++;
        }
        else {
            if( sameCount === 1 ) {
                this.#discon.add( beforeByte );
                if( disconCount + 1 >= 255 ) // 不連続は255回まで
                    this.#discontinuityRelease(this.#discon);
            }
            else this.#outputSame(sameCount,beforeByte);
            this.beforeByte = value; this.#sameCount = 1;
        }
        return  !this.#stopFlg;
    }
    // 不連続データの出力
    #discontinuityRelease( discon , lineEnd = false){
        if( this.discontinuityRelease(discon,lineEnd) ){ // 子クラスの個別処理
            const length = discon.length;
            super.setData(0);super.setData(length);
            this.wordStart();
            discon.forEach(e=>this.wordValue(e));
            this.wordEnd();
        }
        discon.length = 0;
    }
    // 連続データの出力
    #outputSame( count , byte ){
        if( this.outputSame(count , byte) ){ // 子クラスの個別処理
            if( byte !== -1 ) {super.setData(count);super.setData(byte);}
        }
        this.#sameCount = 1;
    }
    lineEnd() { // 行末処理
        this.#lineEnd();
        super.setData(0);super.setData(0)
    }
    end(){ // データ終了
        this.#lineEnd();
        super.setData(0);super.setData(1);
    }
    #lineEnd(){ // 行末処理
        const beforeByte = this.beforeByte;
        const sameCount = this.#sameCount;
        const disconCount = this.#discon.length;

        if( sameCount === 1 && disconCount > 0 ) { // 連続データなし 不連続あり
            this.#discontinuityRelease(this.#discon,true);
        }
        else this.#outputSame( sameCount , beforeByte  );
        this.checkRemainingByte(); // 余っているデータを子クラスで処理
        this.restBeforeByte(); // beforeByteをリセット
    }
    // オーバーライド用定義 無くても動作に問題ない
    initialCheck(){} get beforeByte(){} set beforeByte(v){} restBeforeByte(){}
    discontinuityRelease(){} outputSame(){} checkRemainingByte(){}
};
const SetRle8 = base => class extends SetRle(base) { // BI_RLE8

    #beforeByte=-1;
    get beforeByte(){ return this.#beforeByte;}
    set beforeByte( value ){ this.#beforeByte = value;}
    restBeforeByte(){ this.#beforeByte = -1}

    constructor( size ) {super(size);}
    initialCheck( value ){
        if( this.#beforeByte === -1 ){
            this.#beforeByte = value;
            return false;
        }
        return  true;
    }
    #remainingByte = null;
    discontinuityRelease( discon ,lineEnd ){
        if( lineEnd ) {
            if( discon.length < 255 ) discon.add(this.#beforeByte);
            else this.#remainingByte = this.#beforeByte;
        }
        if( discon.length <= 2  ) {
            discon.forEach(e=>{super.setByte(1);super.setByte(e);});
            return false;
        }
        return true;
    }
    checkRemainingByte(){
        if( this.#remainingByte === null ) return;
        super.setByte(1);super.setByte(this.#remainingByte);
        this.#remainingByte = null;
    }

}
const SetRle4 = base => class extends SetRle(base) {// BI_RLE4
    #beforeByte=[-1,-1]; #count=0;
    constructor( size ) {super(size,254);}
    initialCheck( value ){
        if( this.#beforeByte[0] === -1 ){
            this.#beforeByte[0] = value;
            this.#count = 0;return false;
        }
        if( this.#beforeByte[1] === -1 ){
            this.#beforeByte[1] = value;
            this.#count = 0;return false;
        }
        return  true;
    }
    get beforeByte(){ return this.#beforeByte[this.#count ++ % 2];}
    set beforeByte( value ){
        if( this.initialCheck(value) ){
            this.#beforeByte[0] = this.#beforeByte[1];
            this.#beforeByte[1] = value;
            this.#count = 0;
        }
    }
    restBeforeByte() {this.#beforeByte[0] = this.#beforeByte[1] = -1;}

    discontinuityRelease( discon ,lineEnd ){
        if( lineEnd ) {
            if( discon.length < 254 ){
                discon.add(this.#beforeByte[0]);discon.add(this.#beforeByte[1]);
            }else{
                this.#remainingBytes[0] = this.#beforeByte[0];
                this.#remainingBytes[1] = this.#beforeByte[1];
            }
        }

        const length = discon.length;
        if( length <= 2  ) {
            super.setByte(length);
            const buf = [];discon.forEach(e=>buf.push(e));
            super.setByte((buf[0] << 4) | (length === 2 ? buf[1] : 0));
            return false;
        }
        return true;
    }
    outputSame( count , byte ){
        // countが1のとき、#beforeByteに値がセットされていない可能性がある
        if( this.#beforeByte[0] === -1 ) return false;
        if( this.#beforeByte[1] === -1 ) {
            super.setByte(1);super.setByte(this.#beforeByte[0] << 4 );
        }else {
            super.setByte(count+1);
            super.setByte(this.#beforeByte[0] << 4 | this.#beforeByte[1]);
        }
        this.restBeforeByte();
        return false;
    }
        // 4bit出力制御
    #bit4Count = 0; #bit4 = 0;
    wordStart(){ this.#bit4Count = 0; super.wordStart(); }
    wordValue( v ){
        if( this.#bit4Count % 2 === 0 ) this.#bit4 = v << 4;
        else super.wordValue( this.#bit4 | v );
        this.#bit4Count ++
    }
    wordEnd(){
        if( this.#bit4Count % 2 === 1 ) this.wordValue( 0 );
        super.wordEnd();
    }
    #remainingBytes = [-1,-1];
    checkRemainingByte(){
        if( this.#remainingBytes[0] === -1 && this.#remainingBytes[1] === -1 ) return;
        if( this.#remainingBytes[1] === -1 ) {
            super.setByte(1);super.setByte(this.#remainingBytes[0] << 4 );
        }else{
            super.setByte(2);super.setByte(this.#remainingBytes[0] << 4 | this.#remainingBytes[1]);
        }
        this.#remainingBytes[0] = this.#remainingBytes[1] = -1;
    }
}

SetRle4とSetRle8は親クラスを指定できるようになっていて、OutputCounterクラスを指定すると生成後のバイト数をカウントします。
カウントではなくてデータを生成するときは、パターン1から存在するSetBufferクラスを指定します。

次の関数は、通常(パターン1)とパレット使用(パターン2)とRLE圧縮したときのデータサイズを比較して、一番サイズが小さいものを判定しています。

// パレット/RLEを有効にするかの判定
const judgementPaletteSize = (palette,imgManager)=>{

    if( palette === null ) return [null,0];
    const {width,height} = imgManager;

    const paletteBytes = palette.length * 4; // パレットバイト数
    const bitPerPixel = imgManager.lengthToBitPerPixel(palette);
    // パレット未使用時データサイズ
    const rgbFileBytes = widthAdjustBytes(width * 24) * height;
    // パレット使用時のデータ部のサイズ
    const paletteBmpBytes =
        widthAdjustBytes(width * bitPerPixel) * height;

    if( bitPerPixel === 4 || bitPerPixel === 8 ){ // rle対象
        const maxSize = Math.min( rgbFileBytes , paletteBmpBytes );
        const result = rleEncode(imgManager,maxSize * -1);
        if(result > 0) return [palette,result]
    }
    // パレット出力とRGB出力のサイズを比較
    return ( paletteBmpBytes + paletteBytes > rgbFileBytes )
            ? [null,0] : [palette,0];
};

戻り値は配列で、一つ目がパレットを、二つ目がRLE圧縮したときのサイズがセットされています。
サイズが0のときは、RLE圧縮しないことを意味しています。

 

パターン4:PNG/JPEGでデータ部を生成

四つ目のパターンは、PNGまたはJPEGデータを無理やりBMPファイルに詰め込みます。

ここではソースの一部を紹介しています。
完全なソースは、デモと一緒に次のページで公開しているので、そちらを見てくださいね!

とりあえず、Google Chromeに生成したファイルをドロップすると、表示してくれます。
Firefoxは読み込んでくれません。
他のアプリも読み込んでくれません。

Firefoxやアプリが対応していないのか、Chromeが無理やり読み込んでいるだけで生成したファイルが正しくないのか、わからない状況です。

とりあえず、正しいと思っておきます・・・
実用性ないから時間かける意味ないし・・・

このパターンでの注目点は、次の関数です。

const getImageBuffer = (canvas,type) =>{
    const types = { "PNG":"image/png" , "JPEG" : "image/jpeg" };
    return  new Promise( resolve=>{
        canvas.toBlob( blob =>resolve(blob.arrayBuffer()),types[type],0.7);
    });
}

Canvas画像をPNGまたはJPEGに変換して、バッファーに格納しています。
詳しくは、別の記事で書いているので読んでみてください。

更新日:2023/04/14

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。