Asial Blog

Recruit! Asialで一緒に働きませんか?

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

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

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

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

お絵描きの基本機能



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

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

index.html
  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4.     <meta charset="utf-8">
  5.     <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1, maximum-scale=1, user-scalable=no">
  6.     <script src="components/loader.js"></script>
  7.     <link rel="stylesheet" href="components/loader.css">
  8.     <link rel="stylesheet" href="style.css">
  9.     <script>
  10.         window.addEventListener('load',onLoad,false);
  11.         function onLoad() {
  12.             var width = $("body").width();
  13.             var height = $("body").height();
  14.             $("#my-canvas").attr("width",width-20);
  15.             $("#my-canvas").attr("height",height-60-100);
  16.  
  17.             $(".buttons").height(100);
  18.             $(".buttons").css("top",height-100);
  19.             $(".mybutton").width( (width-44)/3 );
  20.             $(".mybutton").height( 100 );
  21.  
  22.             var myCanvas = $("#my-canvas").get(0);
  23.             var myContext = myCanvas.getContext("2d");
  24.             myContext.strokeStyle = "red";
  25.             myContext.lineWidth = 5;
  26.             var startX = 0;
  27.             var startY = 0;
  28.             var offsetLeft = $("#my-canvas").offset().left;
  29.             var offsetTop = $("#my-canvas").offset().top;
  30.             
  31.             $("#my-canvas").on("touchstart",function(event){
  32.               event.preventDefault();
  33.               var pageX = event.originalEvent.touches[0].pageX;
  34.               var pageY = event.originalEvent.touches[0].pageY;
  35.               startX = pageX - offsetLeft;
  36.               startY = pageY - offsetTop;
  37.             });
  38.             
  39.             $("#my-canvas").on("touchmove",function(event){
  40.               var pageX = event.originalEvent.touches[0].pageX;
  41.               var pageY = event.originalEvent.touches[0].pageY;
  42.               var endX = pageX - offsetLeft;
  43.               var endY = pageY - offsetTop;
  44.               myContext.beginPath();
  45.               myContext.moveTo(startX, startY);
  46.               myContext.lineTo(endX, endY);
  47.               myContext.stroke();
  48.               startX = endX;
  49.               startY = endY;
  50.             });
  51.  
  52.         }
  53.  
  54.         function clearImage() {
  55.             var canvas = $("#my-canvas");
  56.             var myCanvas = canvas.get(0);
  57.             var myContext = myCanvas.getContext("2d");
  58.             myContext.clearRect( 0 , 0 , canvas.width(), canvas.height() );
  59.         }
  60.     </script>
  61. </head>
  62. <body>
  63.     <div class="buttons">
  64.       <div class="mybutton" onClick="saveImage()">save</div>
  65.       <div class="mybutton" onClick="loadImage()">load</div>
  66.       <div class="mybutton" onClick="clearImage()">clear</div>
  67.     </div>
  68.     <canvas id="my-canvas">
  69. </body>
  70. </html>

style.css
  1. html {
  2.     height:100%;
  3. }
  4. body {
  5.     background: #DDDDDD;
  6.     margin:0px;
  7.     padding : 0px;
  8.     height: 100%;
  9. }
  10. #my-canvas {
  11.   margin-top : 30px;
  12.     margin-left : 10px;
  13.     padding : 0px;
  14.     background : #FFFFFF;
  15.     border: thin inset #AAAAAA;
  16. }
  17. .buttons {
  18.     position:absolute;
  19.     top:670px;
  20.     left:20px;
  21. }
  22. .mybutton {
  23.   position:relative;
  24.     float: left;
  25.     text-decoration: none;
  26.     text-align: center;
  27.     font-size: 20px;
  28.     display: block;
  29.     border: 1px solid #000;
  30.     width: 185px;
  31.     height:80px;
  32. }

これで、画面をタップすると線が描けるようになります。
お絵描き機能は基本的なことしかやっていないのですが、内容を簡単に説明すると、次のようになります。
まず、#my-canvasでIDを付けたdivタグをキャンバス化します。これは、次のようにコンテキストを取得することで行います。
  1. var myCanvas = $("#my-canvas").get(0);
  2. var myContext = myCanvas.getContext("2d");
これにtouchstartイベントとtouchmoveイベントをひも付け、それぞれ、始点の設定処理と、線を描く処理を実装しています。具体的な作画部分は、次のようになります。
  1. myContext.beginPath();
  2. myContext.moveTo(startX, startY);
  3. myContext.lineTo(endX, endY);
  4. myContext.stroke();
注意事項は、
  1. event.preventDefault();
の部分です。これがないと、イベントが上位層まで伝達されてしまい、キャンバスが安定しません。(タップ操作により独自に拡大表示したりしてしまう)

保存処理の実装



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

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

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

次のコードをb64utils.jsで保存し、読み込むようにして下さい。
  1. (function(){ 
  2.     
  3.     
  4.     // see
  5.     // https://developer.mozilla.org/ja/docs/Web/JavaScript/Base64_encoding_and_decoding
  6.     function base64DecToArr (sBase64, nBlocksSize) {
  7.  
  8.         var
  9.             sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, ""), nInLen = sB64Enc.length,
  10.             nOutLen = nBlocksSize ? Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize : nInLen * 3 + 1 >> 2, taBytes = new Uint8Array(nOutLen);
  11.  
  12.         for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) {
  13.             nMod4 = nInIdx & 3;
  14.             nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
  15.             if (nMod4 === 3 || nInLen - nInIdx === 1) {
  16.                 for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
  17.                     taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255;
  18.                 }
  19.                 nUint24 = 0;
  20.             }
  21.         }
  22.         return taBytes.buffer;
  23.     }
  24.         
  25.     function b64ToUint6 (nChr) {
  26.  
  27.         return nChr > 64 && nChr < 91 ?
  28.             nChr - 65
  29.             : nChr > 96 && nChr < 123 ?
  30.               nChr - 71
  31.             : nChr > 47 && nChr < 58 ?
  32.               nChr + 4
  33.             : nChr === 43 ?
  34.               62
  35.             : nChr === 47 ?
  36.               63
  37.             :
  38.               0;
  39.     }
  40.  
  41.     // see Corodva
  42.  
  43.     var b64_6bit = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
  44.     var b64_12bit;
  45.  
  46.     var b64_12bitTable = function() {
  47.         b64_12bit = [];
  48.         for (var i=0; i<64; i++) {
  49.             for (var j=0; j<64; j++) {
  50.                 b64_12bit[i*64+j] = b64_6bit[i] + b64_6bit[j];
  51.             }
  52.         }
  53.         b64_12bitTable = function() { return b64_12bit; };
  54.         return b64_12bit;
  55.     };
  56.  
  57.     function uint8ToBase64(rawData) {
  58.         var numBytes = rawData.byteLength;
  59.         var output="";
  60.         var segment;
  61.         var table = b64_12bitTable();
  62.         for (var i=0;i<numBytes-2;i+=3) {
  63.             segment = (rawData[i] << 16) + (rawData[i+1] << 8) + rawData[i+2];
  64.             output += table[segment >> 12];
  65.             output += table[segment & 0xfff];
  66.         }
  67.         if (numBytes - i == 2) {
  68.             segment = (rawData[i] << 16) + (rawData[i+1] << 8);     
  69.             output += table[segment >> 12];
  70.             output += b64_6bit[(segment & 0xfff) >> 6];
  71.             output += '=';
  72.         } else if (numBytes - i == 1) {
  73.             segment = (rawData[i] << 16);
  74.             output += table[segment >> 12];
  75.             output += '==';
  76.         }
  77.         return output;
  78.     }
  79.  
  80.  
  81.     
  82.     
  83.     window.b64utils = {
  84.         decode : base64DecToArr ,
  85.         encode : uint8ToBase64
  86.     };
  87.     
  88.  
  89. })();

読み込むためには、headタグに
  1. <script src="b64utils.js"></script>
を追加します。

これを読み込むと、b64utilsというオブジェクトが利用出来るようになります。
  1. b64utils.decode( base64data );
で、base64dataというbase64文字列を、ArrayBufferオブジェクトに変換出来ます。

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

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

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

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

  1. function saveImage() {
  2.           var myCanvas = $("#my-canvas").get(0);
  3.           var url = myCanvas.toDataURL("image/png");
  4.           var base64data = url.split(',')[1];
  5.  
  6.           var array = b64utils.decode( base64data );
  7.             
  8.           window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, function(fs) {  
  9.             fs.root.getFile("myimage.png" , {create:true, exclusive:false}, 
  10.               function(entry) {
  11.                 entry.createWriter( 
  12.                   function(writer) {
  13.  
  14.                     var cb = function() {
  15.                       console.log("write end"); alert("Save OK");
  16.                     }
  17.  
  18.                     writer.onwrite = cb;
  19.                     writer.onerror = function() { console.log("write error"); }
  20.                     writer.write( array );
  21.  
  22.                   } ,
  23.                   function() {
  24.                     console.log("create write error");
  25.                   }
  26.                 );
  27.               } ,
  28.               function(){ }
  29.             );
  30.           }, function() { });
  31.         }

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

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


読み込み処理の実装



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

  1. function loadImage() {
  2.           window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, function(fs) {   
  3.             fs.root.getFile("myimage.png" , null, 
  4.               function(entry) {
  5.                 entry.file( 
  6.                   function(file) {
  7.                     var reader = new FileReader();
  8.                     reader.onloadend = function(evt) {
  9.  
  10.                       var array = new Uint8Array(evt.target.result);
  11.                       var base64data = b64utils.encode(array);
  12.  
  13.                       var img = new Image();
  14.                       img.src = "data:image/png;base64," + base64data;
  15.                       img.onload = function() {
  16.                         var canvas = $("#my-canvas");
  17.                         var myCanvas = canvas.get(0);
  18.                         var myContext = myCanvas.getContext("2d");
  19.                         myContext.drawImage( img , 0, 0 );
  20.                       }
  21.  
  22.                     };
  23.                     reader.readAsArrayBuffer(file);
  24.                   } ,
  25.                   function() {
  26.                     console.log("create write error");
  27.                   }
  28.                 );
  29.               } ,
  30.               function(){ }
  31.             );
  32.           } , function() {  } );            
  33.         }

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


Android 2.3系への対応



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

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

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

  1. // https://code.google.com/p/todataurl-png-js/
  2.  
  3.  
  4. Number.prototype.toUInt=function(){ return this<0?this+4294967296:this; };
  5. Number.prototype.bytes32=function(){ return [(this>>>24)&0xff,(this>>>16)&0xff,(this>>>8)&0xff,this&0xff]; };
  6. Number.prototype.bytes16sw=function(){ return [this&0xff,(this>>>8)&0xff]; };
  7.  
  8. Array.prototype.adler32=function(start,len){
  9.     switch(arguments.length){ case 0:start=0; case 1:len=this.length-start; }
  10.   var a=1,b=0;
  11.   for(var i=0;i<len;i++){
  12.     a = (a+this[start+i])%65521; b = (b+a)%65521;
  13.   }
  14.   return ((b << 16) | a).toUInt();
  15. };
  16.  
  17. Array.prototype.crc32=function(start,len){
  18.   switch(arguments.length){ case 0:start=0; case 1:len=this.length-start; }
  19.   var table=arguments.callee.crctable;
  20.   if(!table){
  21.     table=[];
  22.     var c;
  23.     for (var n = 0; n < 256; n++) {
  24.       c = n;
  25.       for (var k = 0; k < 8; k++)
  26.         c = c & 1?0xedb88320 ^ (c >>> 1):c >>> 1;
  27.       table[n] = c.toUInt();
  28.     }
  29.     arguments.callee.crctable=table;
  30.   }
  31.   var c = 0xffffffff;
  32.   for (var i = 0; i < len; i++)
  33.     c = table[(c ^ this[start+i]) & 0xff] ^ (c>>>8);
  34.  
  35.   return (c^0xffffffff).toUInt();
  36. };
  37.  
  38.  
  39. (function(){
  40.   var toDataURL=function(){
  41.     var imageData=Array.prototype.slice.call(this.getContext("2d").getImageData(0,0,this.width,this.height).data);
  42.     var w=this.width;
  43.     var h=this.height;
  44.     var stream=[
  45.       0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,
  46.       0x00,0x00,0x00,0x0d,0x49,0x48,0x44,0x52
  47.     ];
  48.     Array.prototype.push.apply(stream, w.bytes32() );
  49.     Array.prototype.push.apply(stream, h.bytes32() );
  50.     stream.push(0x08,0x06,0x00,0x00,0x00);
  51.     Array.prototype.push.apply(stream, stream.crc32(12,17).bytes32() );
  52.     var len=h*(w*4+1);
  53.     for(var y=0;y<h;y++)
  54.       imageData.splice(y*(w*4+1),0,0);
  55.     var blocks=Math.ceil(len/32768);
  56.     Array.prototype.push.apply(stream, (len+5*blocks+6).bytes32() );
  57.     var crcStart=stream.length;
  58.     var crcLen=(len+5*blocks+6+4);
  59.     stream.push(0x49,0x44,0x41,0x54,0x78,0x01);
  60.     for(var i=0;i<blocks;i++){
  61.       var blockLen=Math.min(32768,len-(i*32768));
  62.       stream.push(i==(blocks-1)?0x01:0x00);
  63.       Array.prototype.push.apply(stream, blockLen.bytes16sw() );
  64.       Array.prototype.push.apply(stream, (~blockLen).bytes16sw() );
  65.       var id=imageData.slice(i*32768,i*32768+blockLen);
  66.       Array.prototype.push.apply(stream, id );
  67.     }
  68.     Array.prototype.push.apply(stream, imageData.adler32().bytes32() );
  69.     Array.prototype.push.apply(stream, stream.crc32(crcStart, crcLen).bytes32() );
  70.  
  71.     stream.push(0x00,0x00,0x00,0x00,0x49,0x45,0x4e,0x44);
  72.     Array.prototype.push.apply(stream, stream.crc32(stream.length-4, 4).bytes32() );
  73.     return "data:image/png;base64,"+btoa(stream.map(function(c){ return String.fromCharCode(c); }).join(''));
  74.   };
  75.   
  76.   var tdu=HTMLCanvasElement.prototype.toDataURL;
  77.   
  78.   HTMLCanvasElement.prototype.toDataURL=function(type){
  79.  
  80.       var res=tdu.apply(this,arguments);
  81.       if(res == "data:,"){
  82.         HTMLCanvasElement.prototype.toDataURL=toDataURL;
  83.         return this.toDataURL();
  84.       }else{
  85.         HTMLCanvasElement.prototype.toDataURL=tdu;
  86.         return res;
  87.       }
  88.   }
  89.  
  90. })();

 本体側からの読み込みは
  1. <script src="todataurl.js"></script>
になります。

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

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

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

  1. if (! window.ArrayBuffer) {
  2.     window.ArrayBuffer = Array;
  3.  
  4. //    https://gist.github.com/DimitarChristoff/5583998
  5.     Object.prototype.toString.apply = function(that, args){
  6.         return Function.prototype.apply.call(this, that, []);
  7.     };
  8.  
  9.     Object.prototype.toString.call = function(that){
  10.           if (that instanceof ArrayBuffer) {
  11.               return "[object ArrayBuffer]";
  12.           }
  13.           var args = [].slice.call(arguments, 1);
  14.           return this.apply(that, args);
  15.     };
  16.     
  17. }
  18.  
  19. // http://stackoverflow.com/questions/16735218/create-a-substitute-of-uint8array-on-android-2-x
  20. // partially modified for ArrayBuffer case
  21.         (function() {
  22.             try {
  23.                 var a = new Uint8Array(1);
  24.                 return; //no need
  25.             } catch(e) { }
  26.  
  27.             function subarray(start, end) {
  28.                 return this.slice(start, end);
  29.             }
  30.  
  31.             function set_(array, offset) {
  32.                 if (arguments.length < 2) offset = 0;
  33.                 for (var i = 0, n = array.length; i < n; ++i, ++offset)
  34.                     this[offset] = array[i] & 0xFF;
  35.             }
  36.  
  37.             // we need typed arrays
  38.             function TypedArray(arg1) {
  39.                 var result;
  40.                 if (typeof arg1 === "number") {
  41.                     result = new ArrayBuffer(arg1);
  42.                     for (var i = 0; i < arg1; ++i)
  43.                         result[i] = 0;
  44.                 } else
  45.                    result = arg1.slice(0);
  46.                 result.subarray = subarray;
  47.                 result.buffer = result;
  48.                 result.byteLength = result.length;
  49.                 result.set = set_;
  50.                 if (typeof arg1 === "object" && arg1.buffer)
  51.                     result.buffer = arg1.buffer;
  52.  
  53.                 return result;
  54.             }
  55.  
  56.             window.Uint8Array = TypedArray;
  57.             window.Uint32Array = TypedArray;
  58.             window.Int32Array = TypedArray;
  59.         })();

本体からの読み込みは
  1. <script src="uint8array.js"></script>
です。これも、すでに定義されている場合は何もしないので、全機種で読み込んで問題ないです。

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

上記の修正を行っても、Android 2.3系ではメモリが少ないために、正常な動作が難しいかも知れません。その場合は、
  1. $("#my-canvas").attr("width",width-20);
  2. $("#my-canvas").attr("height",height-60-100);
の部分のwidthとheightを小さくして、作画領域を小さくし、メモリを節約するようにしてみて下さい。

まとめ



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