お絵描きアプリと画像の保存処理の実装

 こんにちは。内藤です。
 HTML5でアプリを作成する場合、画像は予め用意されたpngファイルをそのまま表示することが普通で、アプリ側でエフェクトをかけたりすることはあまりありません。けれども、HTML5のCanvasを使うことでアプリ内で画像を作成する機能は作れるので、ここでは作った画像を画像ファイルとして扱い、保存する部分までの実装方法について説明します。

 これを応用すれば、アプリで作成した画像をメールで転送したり、サーバーにアップロードしたり、といったことも可能です。

 内部的には、JavaScriptでバイナリを扱うArrayBufferオブジェクトが登場しますので、これの使い方も参考にして下さい。

お絵描きの基本機能

 まずは、Monacaから最小限のアプリを作り、JS/CSSコンポーネントよりjQuery(Monaca version)を入れておきます。

次のリストによりお絵描き機能を完成させます。

index.html


<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1, maximum-scale=1, user-scalable=no">
    <script src="components/loader.js"></script>
    <link rel="stylesheet" href="components/loader.css">
    <link rel="stylesheet" href="style.css">
    <script>
        window.addEventListener('load',onLoad,false);
        function onLoad() {
            var width = $("body").width();
            var height = $("body").height();
            $("#my-canvas").attr("width",width-20);
            $("#my-canvas").attr("height",height-60-100);
            $(".buttons").height(100);
            $(".buttons").css("top",height-100);
            $(".mybutton").width( (width-44)/3 );
            $(".mybutton").height( 100 );
            var myCanvas = $("#my-canvas").get(0);
            var myContext = myCanvas.getContext("2d");
            myContext.strokeStyle = "red";
            myContext.lineWidth = 5;
            var startX = 0;
            var startY = 0;
            var offsetLeft = $("#my-canvas").offset().left;
            var offsetTop = $("#my-canvas").offset().top;
            
            $("#my-canvas").on("touchstart",function(event){
              event.preventDefault();
              var pageX = event.originalEvent.touches[0].pageX;
              var pageY = event.originalEvent.touches[0].pageY;
              startX = pageX - offsetLeft;
              startY = pageY - offsetTop;
            });
            
            $("#my-canvas").on("touchmove",function(event){
              var pageX = event.originalEvent.touches[0].pageX;
              var pageY = event.originalEvent.touches[0].pageY;
              var endX = pageX - offsetLeft;
              var endY = pageY - offsetTop;
              myContext.beginPath();
              myContext.moveTo(startX, startY);
              myContext.lineTo(endX, endY);
              myContext.stroke();
              startX = endX;
              startY = endY;
            });
        }
        function clearImage() {
            var canvas = $("#my-canvas");
            var myCanvas = canvas.get(0);
            var myContext = myCanvas.getContext("2d");
            myContext.clearRect( 0 , 0 , canvas.width(), canvas.height() );
        }
    </script>
</head>
<body>
    <div class="buttons">
      <div class="mybutton" onClick="saveImage()">save</div>
      <div class="mybutton" onClick="loadImage()">load</div>
      <div class="mybutton" onClick="clearImage()">clear</div>
    </div>
    <canvas id="my-canvas">
</body>
</html>

style.css


html {
    height:100%;
}
body {
    background: #DDDDDD;
    margin:0px;
    padding : 0px;
    height: 100%;
}
#my-canvas {
margin-top : 30px;
    margin-left : 10px;
    padding : 0px;
    background : #FFFFFF;
    border: thin inset #AAAAAA;
}
.buttons {
    position:absolute;
    top:670px;
    left:20px;
}
.mybutton {
position:relative;
    float: left;
    text-decoration: none;
    text-align: center;
    font-size: 20px;
    display: block;
    border: 1px solid #000;
    width: 185px;
    height:80px;
}

これで、画面をタップすると線が描けるようになります。
お絵描き機能は基本的なことしかやっていないのですが、内容を簡単に説明すると、次のようになります。
まず、#my-canvasでIDを付けたdivタグをキャンバス化します。これは、次のようにコンテキストを取得することで行います。


var myCanvas = $("#my-canvas").get(0);
var myContext = myCanvas.getContext("2d");

これにtouchstartイベントとtouchmoveイベントをひも付け、それぞれ、始点の設定処理と、線を描く処理を実装しています。具体的な作画部分は、次のようになります。


myContext.beginPath();
myContext.moveTo(startX, startY);
myContext.lineTo(endX, endY);
myContext.stroke();

注意事項は、


event.preventDefault();

の部分です。これがないと、イベントが上位層まで伝達されてしまい、キャンバスが安定しません。(タップ操作により独自に拡大表示したりしてしまう)

保存処理の実装

保存するためには、まずcanvasのデータをバイト列にする必要があります。そのためには、canvasにあるtoDataURL()メソッドを使います。このメソッドは、iOSでもAndroidでも利用出来るのですが、残念ながら、Android 2.3系の一部の機種では正常に動作しません。Android 2.3系の一部の機種(HTC Evoなど)では、同様の機能の処理を自分で実装する必要があります(後述)。

次に、toDataURL()で変換した画像ファイルは、基本的にbase64という書式で記述された文字列
になっています。この文字列をそのまま保存することも出来ますが、保存してもこれは「画像ファイル」にはなりません。ただの、文字列が記述されたファイル(テキストファイル)になってしまいます。

そこで、今度はbase64文字列を、バイナリに変換する必要があります。

次のコードをb64utils.jsで保存し、読み込むようにして下さい。


(function(){ 
    
    
    // see
    // https://developer.mozilla.org/ja/docs/Web/JavaScript/Base64_encoding_and_decoding
    function base64DecToArr (sBase64, nBlocksSize) {
        var
            sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, ""), nInLen = sB64Enc.length,
            nOutLen = nBlocksSize ? Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize : nInLen * 3 + 1 >> 2, taBytes = new Uint8Array(nOutLen);
        for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) {
            nMod4 = nInIdx  & 3;
            nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
            if (nMod4 === 3 || nInLen - nInIdx === 1) {
                for (nMod3 = 0; nMod3 < 3  & & nOutIdx < nOutLen; nMod3++, nOutIdx++) {
                    taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3  & 24)  & 255;
                }
                nUint24 = 0;
            }
        }
        return taBytes.buffer;
    }
        
    function b64ToUint6 (nChr) {
        return nChr > 64  & & nChr < 91 ?
            nChr - 65
            : nChr > 96  & & nChr < 123 ?
              nChr - 71
            : nChr > 47  & & nChr < 58 ?
              nChr + 4
            : nChr === 43 ?
              62
            : nChr === 47 ?
              63
            :
              0;
    }
    // see Corodva
    var b64_6bit = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    var b64_12bit;
    var b64_12bitTable = function() {
        b64_12bit = [];
        for (var i=0; i<64; i++) {
            for (var j=0; j<64; j++) {
                b64_12bit[i*64+j] = b64_6bit[i] + b64_6bit[j];
            }
        }
        b64_12bitTable = function() { return b64_12bit; };
        return b64_12bit;
    };
    function uint8ToBase64(rawData) {
        var numBytes = rawData.byteLength;
        var output="";
        var segment;
        var table = b64_12bitTable();
        for (var i=0;i<numBytes-2;i+=3) {
            segment = (rawData[i] << 16) + (rawData[i+1] << 8) + rawData[i+2];
            output += table[segment >> 12];
            output += table[segment  & 0xfff];
        }
        if (numBytes - i == 2) {
            segment = (rawData[i] << 16) + (rawData[i+1] << 8);     
            output += table[segment >> 12];
            output += b64_6bit[(segment  & 0xfff) >> 6];
            output += '=';
        } else if (numBytes - i == 1) {
            segment = (rawData[i] << 16);
            output += table[segment >> 12];
            output += '==';
        }
        return output;
    }
    
    
    window.b64utils = {
        decode : base64DecToArr ,
        encode : uint8ToBase64
    };
    
})();

読み込むためには、headタグに


   <script src="b64utils.js"></script>

を追加します。

これを読み込むと、b64utilsというオブジェクトが利用出来るようになります。


  b64utils.decode( base64data );

で、base64dataというbase64文字列を、ArrayBufferオブジェクトに変換出来ます。

ArrayBufferオブジェクトは、JavaScriptでバイナリを扱うためのオブジェクトです。
なお、Android 2.3では、ArrayBufferオブジェクトが実装されていませんので、別途、ArrayBufferを自分で実装する必要があります(後述)。

ArrayBufferオブジェクトが出来たら、あとはこれをFileSystemやFileクラスで処理すれば保存出来ます。

Monaca IDEから「Cordovaプラグインの管理」を選択し、「File」プラグインを有効にしておいて下さい。

そして、保存処理の実装は以下のようになります。簡単のため、保存するときのファイル名はmyimage.pngに固定してあります。


        function saveImage() {
          var myCanvas = $("#my-canvas").get(0);
          var url = myCanvas.toDataURL("image/png");
          var base64data = url.split(',')[1];
          var array = b64utils.decode( base64data );
            
          window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, function(fs) {  
            fs.root.getFile("myimage.png" , {create:true, exclusive:false}, 
              function(entry) {
                entry.createWriter( 
                  function(writer) {
                    var cb = function() {
                      console.log("write end"); alert("Save OK");
                    }
                    writer.onwrite = cb;
                    writer.onerror = function() { console.log("write error"); }
                    writer.write( array );
                  } ,
                  function() {
                    console.log("create write error");
                  }
                );
              } ,
              function(){ }
            );
          }, function() { });
        }

これで、画像の保存処理が実装出来ました。Monacaデバッガーからでも動作するので、動かしてみて下さい。

保存した画像はアプリ内にあるので、このままではアプリからは見れません。Androidであれば、ファイルビューアーアプリを使ってみれば、ルートフォルダ(デバイスにより異なります)にmyimage.pngがあることが分かります。iOSの場合は、ちょっと面倒ですが、PCとUSB接続を行い、iFunBoxなどのツールを使えば確認が出来ます。例えば、Monacaデバッガーを使っているのであれば、ユーザーApp>Monaca>Documentsの下にmyimage.pngがあることが分かります。

読み込み処理の実装

 次に読み込み処理を作り込んでいきます。
 保存処理とは逆に読み込んだArrayBufferオブジェクトを、b64utils.encode()メソッドを使って
base64文字列に変換します。これを、data URL Schemeを使ってImageクラスに設定します。


        function loadImage() {
          window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, function(fs) {   
            fs.root.getFile("myimage.png" , null, 
              function(entry) {
                entry.file( 
                  function(file) {
                    var reader = new FileReader();
                    reader.onloadend = function(evt) {
                      var array = new Uint8Array(evt.target.result);
                      var base64data = b64utils.encode(array);
                      var img = new Image();
                      img.src = "data:image/png;base64," + base64data;
                      img.onload = function() {
                        var canvas = $("#my-canvas");
                        var myCanvas = canvas.get(0);
                        var myContext = myCanvas.getContext("2d");
                        myContext.drawImage( img , 0, 0 );
                      }
                    };
                    reader.readAsArrayBuffer(file);
                  } ,
                  function() {
                    console.log("create write error");
                  }
                );
              } ,
              function(){ }
            );
          } , function() {  } );            
        }

 これで、読み込み処理も出来ました。これで、作画、保存、読み込み、クリアのすべての動作が出来るようになりました。

Android 2.3系への対応

 いまではもう古くなってしまったAndroid 2.3系ですが、一部機種ではCanvasの画像をpng化するのに必要なtoDataURL()メソッドが動かない上に、バイナリを扱うArrayBufferオブジェクトもありません。(一部機種では、ArrayBufferが通常のArrayクラスとして実装されている)
 また、ArrayBufferオブジェクトをJavaScriptから操作するためのUint8Arrayオブジェクトもありません。

 そのため、それをすべて自前で用意する必要があります。

 まず、toDataURL()メソッドは、以下で実装出来ます。todataurl.jsというファイル名で保存して下さい。


// https://code.google.com/p/todataurl-png-js/
Number.prototype.toUInt=function(){ return this<0?this+4294967296:this; };
Number.prototype.bytes32=function(){ return [(this>>>24) &0xff,(this>>>16) &0xff,(this>>>8) &0xff,this &0xff]; };
Number.prototype.bytes16sw=function(){ return [this &0xff,(this>>>8) &0xff]; };
Array.prototype.adler32=function(start,len){
    switch(arguments.length){ case 0:start=0; case 1:len=this.length-start; }
var a=1,b=0;
for(var i=0;i<len;i++){
a = (a+this[start+i])%65521; b = (b+a)%65521;
}
return ((b << 16) | a).toUInt();
};
Array.prototype.crc32=function(start,len){
switch(arguments.length){ case 0:start=0; case 1:len=this.length-start; }
var table=arguments.callee.crctable;
if(!table){
table=[];
var c;
for (var n = 0; n < 256; n++) {
c = n;
for (var k = 0; k < 8; k++)
c = c  & 1?0xedb88320 ^ (c >>> 1):c >>> 1;
table[n] = c.toUInt();
}
arguments.callee.crctable=table;
}
var c = 0xffffffff;
for (var i = 0; i < len; i++)
c = table[(c ^ this[start+i])  & 0xff] ^ (c>>>8);
return (c^0xffffffff).toUInt();
};
(function(){
var toDataURL=function(){
var imageData=Array.prototype.slice.call(this.getContext("2d").getImageData(0,0,this.width,this.height).data);
var w=this.width;
var h=this.height;
var stream=[
0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,
0x00,0x00,0x00,0x0d,0x49,0x48,0x44,0x52
];
Array.prototype.push.apply(stream, w.bytes32() );
Array.prototype.push.apply(stream, h.bytes32() );
stream.push(0x08,0x06,0x00,0x00,0x00);
Array.prototype.push.apply(stream, stream.crc32(12,17).bytes32() );
var len=h*(w*4+1);
for(var y=0;y<h;y++)
imageData.splice(y*(w*4+1),0,0);
var blocks=Math.ceil(len/32768);
Array.prototype.push.apply(stream, (len+5*blocks+6).bytes32() );
var crcStart=stream.length;
var crcLen=(len+5*blocks+6+4);
stream.push(0x49,0x44,0x41,0x54,0x78,0x01);
for(var i=0;i<blocks;i++){
var blockLen=Math.min(32768,len-(i*32768));
stream.push(i==(blocks-1)?0x01:0x00);
Array.prototype.push.apply(stream, blockLen.bytes16sw() );
Array.prototype.push.apply(stream, (~blockLen).bytes16sw() );
var id=imageData.slice(i*32768,i*32768+blockLen);
Array.prototype.push.apply(stream, id );
}
Array.prototype.push.apply(stream, imageData.adler32().bytes32() );
Array.prototype.push.apply(stream, stream.crc32(crcStart, crcLen).bytes32() );
stream.push(0x00,0x00,0x00,0x00,0x49,0x45,0x4e,0x44);
Array.prototype.push.apply(stream, stream.crc32(stream.length-4, 4).bytes32() );
return "data:image/png;base64,"+btoa(stream.map(function(c){ return String.fromCharCode(c); }).join(''));
};
var tdu=HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL=function(type){
var res=tdu.apply(this,arguments);
if(res == "data:,"){
HTMLCanvasElement.prototype.toDataURL=toDataURL;
return this.toDataURL();
}else{
HTMLCanvasElement.prototype.toDataURL=tdu;
return res;
}
}
})();

 本体側からの読み込みは


    <script src="todataurl.js"></script>

になります。

 これは、iOSやAndroid 4系以上で、toDataURL()がすでに実装されていて正常に動作する場合は、
特に何もしないようになっていますので、全機種で読み込んで大丈夫です。

 この実装でpng化は出来るのですが、非常に遅いです。デバイスの解像度が高く、CPUパワーの低い機種で利用する場合、相当時間がかかってしまうので、気をつけて下さい。

 次に、ArrayBufferオブジェクト、Uint8Arrayオブジェクトへの対応ですが、これは次のコードを読み込めば解決します。uint8array.jsというファイル名で保存して下さい。


if (! window.ArrayBuffer) {
    window.ArrayBuffer = Array;
//    https://gist.github.com/DimitarChristoff/5583998
    Object.prototype.toString.apply = function(that, args){
        return Function.prototype.apply.call(this, that, []);
    };
 
    Object.prototype.toString.call = function(that){
          if (that instanceof ArrayBuffer) {
              return "[object ArrayBuffer]";
          }
          var args = [].slice.call(arguments, 1);
          return this.apply(that, args);
    };
    
}
// http://stackoverflow.com/questions/16735218/create-a-substitute-of-uint8array-on-android-2-x
// partially modified for ArrayBuffer case
        (function() {
            try {
                var a = new Uint8Array(1);
                return; //no need
            } catch(e) { }
            function subarray(start, end) {
                return this.slice(start, end);
            }
            function set_(array, offset) {
                if (arguments.length < 2) offset = 0;
                for (var i = 0, n = array.length; i < n; ++i, ++offset)
                    this[offset] = array[i]  & 0xFF;
            }
            // we need typed arrays
            function TypedArray(arg1) {
                var result;
                if (typeof arg1 === "number") {
                    result = new ArrayBuffer(arg1);
                    for (var i = 0; i < arg1; ++i)
                        result[i] = 0;
                } else
                   result = arg1.slice(0);
                result.subarray = subarray;
                result.buffer = result;
                result.byteLength = result.length;
                result.set = set_;
                if (typeof arg1 === "object"  & & arg1.buffer)
                    result.buffer = arg1.buffer;
                return result;
            }
            window.Uint8Array = TypedArray;
            window.Uint32Array = TypedArray;
            window.Int32Array = TypedArray;
        })();
     

本体からの読み込みは


    <script src="uint8array.js"></script>

です。これも、すでに定義されている場合は何もしないので、全機種で読み込んで問題ないです。

このArrayBufferクラスは、通常の配列をバイナリ処理用に利用しているだけになります。そして、CordovaのFile処理を行うクラスに「バイナリ用のクラスである」と思わせるために、Uint8ArrayやUint32Arrayなども定義してあります。

上記の修正を行っても、Android 2.3系ではメモリが少ないために、正常な動作が難しいかも知れません。その場合は、


$("#my-canvas").attr("width",width-20);
$("#my-canvas").attr("height",height-60-100);

の部分のwidthとheightを小さくして、作画領域を小さくし、メモリを節約するようにしてみて下さい。

まとめ

 画像処理をはじめとして、JavaScriptでバイナリを扱うこと自体、あまり多くはないかも知れませんが、知っておくと応用範囲は広いです。JavaやCなどと違い、直接バイナリを扱うのには不向きなJavaScriptですが、ぜひ一度、試してみて下さい。