このページは、【JavaScript】 自力でCanvas画像をBMPに変換する
『パターン3:RLE(ランレングス)圧縮を行う』のデモページです



ソースコード

HTML

<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="./bitmap-ui.js" type="module"></script>
<style>
    #inch{
      width: 1in;
      height: 1in;
      padding: 0;
      border: 0;
      box-sizing: border-box;
    }
</style>
</head>
<body>
    <div id="inch"></div>
    <p><input type="file" id="file_select"></p>
    <p><button id="button">bmp生成</button></p>
    <p><canvas id="canvas"></canvas></p>
</body>
</html>

bitmap-ui.js

import {imageDataToBmp}  from "./imagedatatobmp.js";
(()=>{

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

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("読み込めませんでした");

    const reader = new FileReader();
    reader.onload = () => image.src = reader.result

    document.getElementById(fileSelectId)
        .addEventListener("change",function(){
            reader.readAsDataURL(this.files[0]);
        });

    document.getElementById(buttonId)
        .addEventListener("click",()=>bitmapDownload(context,dpm));
};
    // ビットマップファイルダウンロード
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);

        });
};

})();

imagedatatobmp.js

export {imageDataToBmp};
const imageDataToBmp = (()=>{
    "use strict";

    // 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バイト合わせ
    const widthAdjustBytes = widthBits =>Math.ceil(Math.ceil(widthBits/8)/4) * 4;
    // バッファーセットクラス
    const SetBuffer = class{
        #buffer; #pos=0;
        constructor(size) {this.#buffer = new Uint8Array( size );}
        setData(value){this.#buffer[this.#pos++] = value;return true;}
        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(){ // 4 バイト合わせ
            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]);
        }
    };

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

        const rleSize = imgMgr.rleSize;
        if( rleSize > 0 ) return rleEncode(imgMgr,rleSize);

        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 );
    };
    // 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);
    };
    // rle 参考 : https://note.affi-sapo-sv.com/js-run-length-encoding.php
    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;
        }
    }
    // 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;
    }
    // パレット/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];
    };
    // パレットをビットマップファイルでの形式に変換(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;
    };
    // imageData の管理
    const imgManager = class {
        #srcData; #width; #height;  #palette; #isConverted = false;
        #rleSize;
        get rleSize(){ return this.#rleSize;}
        constructor( imageData,width,height ) {
            this.#width=width;this.#height=height;
            this.#srcData = new DataView(imageData.buffer);
            // パレット抽出後、パレット有効判定
            this.#palette = extractPalette(this.#srcData);
            [this.#palette,this.#rleSize] = judgementPaletteSize(this.#palette,this);
        }
        get compression(){
            // BI_RLE8 ... 1 BI_RLE4 ... 2
            if( this.rleSize > 0 ) return this.bitPerPixel === 4 ? 2 : 1;
            return 0;
        }
        get width(){ return this.#width;}
        get height(){ return this.#height;}
        get bitPerPixel(){return this.lengthToBitPerPixel(this.#palette);}
        lengthToBitPerPixel(palette){
            if( palette === null ) return 24;
            const len = palette.length;
            if( len <= 2 ) return 1;
            return len < 17 ? 4 : 8;
        }
        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;
        }
        get palette(){ // ビットマップファイル上での形式に変換したパレット情報を返す
            if( this.#palette === null ) {
                const buffer = [];buffer.byteLength = 0;
                return {buffer:buffer,length:0}; // DataViewを偽装
            }
            if( this.#isConverted ) return this.#palette;
            this.#isConverted = true;
            return convertPalette(this.#palette);
        }
    };

    // ヘッダー生成用フォーマット定数
    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;
    };
    // BMPバイナリイメージの生成
    return (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) );
    };

})();