お絵描きアプリと画像の保存処理の実装
こんにちは。内藤です。
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ですが、ぜひ一度、試してみて下さい。