今更だけどCropper.jsでの画像トリミングについて書いてみました!

2019/05/10 2019/05/10 #javascript #Html5 #cropper

WEBサービスにおける画像トリミングについて考えてみた。

とある自作WEBサービスの開発中、ユーザーアイコンを登録できるようにしよう!と考えつきアップロードしたはいいものの、やっぱりトリミングできた方がいいよね。と考えGoogle先生に色々聞いてみました。
Cropper.js(https://fengyuanchen.github.io/cropperjs/)
なるものを発見したのはいいのですが、そこで苦労したので備忘録ついでに書いてみました。

留意点

  • これまでにもCropperでのサンプルは色々上がっておりましたが、またそれとは違うパターンにしておいたはずですので参考になればと思います。
  • サーバーサイドについては触れていません。

サンプル

<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="stylesheet" href="dist/cropper.css" charset="UTF-8">
        <title>Cropper Sample</title>
        <style>
            /* 下記は円形にするなら必須です。 */
            .cropper-view-box,
            .cropper-face {
                border-radius: 50%;
            }
            /* 下記はできれば必要なスタイルかと思います。(厳密にはスタイルなど必要ありませんが、最低現のスタイルとしてという意味です。) */
            .cropper-container{
                width: 100%;
            }
            /* 下記は必須ではありません。 Sampleを見やすくするために作成しました。 */
            main{
                width: 50%;
                margin: 0 auto;
            }
            main .triming-image{
                width: 100%;
                height: 100px;
                border: dashed #000 1px;
                cursor: pointer;
            }
            main #trimed_image{
                height: 500px;
            }
        </style>
    </head>
    <body>
        <main>
            <h1>Cropper Sample</h1>
            <p>ここでのサンプルはTwitterアイコン風に丸型とします。</p>
            <p>特にCSSに指定はありません。</p>
            <p>各自で自由に設定してください。</p>
            <div class="cropper-container">
                <p><input type="file" id="triming_image" name="triming_image" class="triming-image" required /></p>
                <p><img src="" alt="トリミング画像" id="trimed_image" style="display: none;" /></p>
                <p><input type="button" id="crop_btn" value="画像をトリミングして送信" /></p>
            </div>
            <p>トリミング結果が下記に表示されます。</p>
            <p>例ではAjaxにて送信済みでので、下記機能に特に意味がありません。</p>
            <p>(結果表示したところですでに送信済みですので。)</p>
            <p>Cropper.jsそのものに画像操作は<a href="https://fengyuanchen.github.io/cropper/" target="_blank">https://fengyuanchen.github.io/cropper/</a></p>
            <div id="result"></div>
        </main>
    </body>
    <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js" charset="UTF-8"></script>
    <script type="text/javascript" src="dist/cropper.js" charset="UTF-8"></script>
    <script type="text/javascript">
        /**
         * 丸くトリミングするために必要な関数です。
         * キャンバスの画像を円形に座標計算し、切り取って返しています。
         */
        function getRoundedCanvas(sourceCanvas) {
            var canvas = document.createElement('canvas');
            var context = canvas.getContext('2d');
            var width = sourceCanvas.width;
            var height = sourceCanvas.height;

            canvas.width = width;
            canvas.height = height;
            context.imageSmoothingEnabled = true;
            context.drawImage(sourceCanvas, 0, 0, width, height);
            context.globalCompositeOperation = 'destination-in';
            context.beginPath();
            context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true);
            context.fill();
            return canvas;
        }

        $(function(){
            $('#triming_image').on('change', function(event){
                var trimingImage = event.target.files;
                
                // imageタグは1つしかファイルを送信できない仕組みと複数送信する仕組みの二通りありますので、サーバー側でチェックを忘れないようにしてください。
                if(trimingImage.length > 1){
                    console.log(trimingImage.length + 'つのファイルが選択されました。');
                    return false;
                }
                // 改め代入します。
                trimingImage = trimingImage[0];

                // 画像のチェックを行いますが、あくまでjsでのチェックなのでサーバーサイドでもう一度チェックを行ってください。
                if(!trimingImage.type.match('image/jp.*') // jpg jpeg でない
                 &&!trimingImage.type.match('image/png') // png でない
                 &&!trimingImage.type.match('image/gif') // gif でない
                 &&!trimingImage.type.match('image/bmp') // bmp でない
                ){
                    alert('No Support ' + trimingImage.type + ' type image');
                    $(this).val('');
                    return false;
                }
                
                var fileReader = new FileReader();
                fileReader.onload = function(e){
                    var int32View = new Uint8Array(e.target.result);
                    // see https://en.wikipedia.org/wiki/List_of_file_signatures
                    // ファイルのヘッダを参照し、マイムタイプを疑似的に取得します。フレームワークによってはもっと簡単に正確に読めるものもあります。
                    // 下記は厳しい設定です。正規の手順を踏んでもアップロードできないカメラなどがあります。
                    // (私の環境ではアクションカメラの写真などは下記に引っ掛かりました。)
                    if((int32View.length>4 && int32View[0]==0xFF && int32View[1]==0xD8 && int32View[2]==0xFF && int32View[3]==0xE0)
                    || (int32View.length>4 && int32View[0]==0xFF && int32View[1]==0xD8 && int32View[2]==0xFF && int32View[3]==0xDB)
                    || (int32View.length>4 && int32View[0]==0xFF && int32View[1]==0xD8 && int32View[2]==0xFF && int32View[3]==0xD1)
                    || (int32View.length>4 && int32View[0]==0x89 && int32View[1]==0x50 && int32View[2]==0x4E && int32View[3]==0x47)
                    || (int32View.length>4 && int32View[0]==0x47 && int32View[1]==0x49 && int32View[2]==0x46 && int32View[3]==0x38)
                    || (int32View.length=2 && int32View[0]==0x42 && int32View[1]==0x4D && int32View[2]==0x46 && int32View[3]==0x38)
                    ){ 
                        // success
                        $('#trimed_image').css('display', 'block');
                        $('#trimed_image').attr('src', URL.createObjectURL(trimingImage));
                        return true;
                    } else {
                        // failed
                        alert('No Support ' + trimingImage.type + ' type image');
                        // exeファイルのアップロードを考えると下記よりもいいプラクティスがある可能性があります。
                        $('#trimed_image').val('');
                        return false;
                    }
                };
                fileReader.readAsArrayBuffer(trimingImage);
                
                fileReader.onloadend = function(e){
                    var image = document.getElementById('trimed_image');
                    var button = document.getElementById('crop_btn');

                    var croppable = false;
                    var cropper = new Cropper(image, {
                        aspectRatio: 1,
                        viewMode: 1,
                        ready: function () {
                            croppable = true;
                        },
                    });

                    // fileReaderが完了した後にボタンクリックイベントを作成する必要があります。
                    button.onclick = function () {
                        var croppedCanvas;

                        if (!croppable) {
                            alert('トリミングする画像が設定されていません。');
                            return false;
                        }

                        // cropper.jsに用意されている機能です。
                        croppedCanvas = cropper.getCroppedCanvas();
                        // 下記toBlob関数はブラウザによって名前が違います。
                        var blob;
                        if(croppedCanvas.toBlob){
                            croppedCanvas.toBlob(function(blob){
                                var trimedImageForm = new FormData();
                                trimedImageForm.append('blob', blob);
                                // この例ではAjaxにて送信します。
                                $.ajax({
                                    url: '', // POST送信先
                                    type: 'post',
                                    processData: false,
                                    contentType: false,
                                    data: trimedImageForm,
                                }).done(function( jsonResponse ){
                                    var responese = $.parseJSON(jsonResponse);
                                    if(responese.status == 'success'){
                                        console.log(responese);
                                        alert('アップロードしました。');
                                    }else if(responese.status == 'error'){
                                        alert('画像作成に失敗しました。再度お試しください。\n' + responese.msg);
                                    }else{
                                        alert('システムエラーが発生しました。');
                                    }
                                }).fail(function( responese ) {
                                    alert('システムエラーが発生しました。');
                                    // フレームワークによってはサーバーエラーをjsonで返してくれます。
                                    var responese = $.parseJSON(jsonResponse);
                                });
                            });
                        }else if(croppedCanvas.msToBlob){
                            blob = croppedCanvas.msToBlob();
                            var trimedImageForm = new FormData();
                            trimedImageForm.append('blob', blob);
                            // この例ではAjaxにて送信します。
                            $.ajax({
                                url: '', // POST送信先
                                type: 'post',
                                processData: false,
                                contentType: false,
                                data: trimedImageForm,
                            }).done(function( jsonResponse ){
                                var responese = $.parseJSON(jsonResponse);
                                if(responese.status == 'success'){
                                    console.log(responese);
                                    alert('アップロードしました。');
                                }else if(responese.status == 'error'){
                                    alert('画像作成に失敗しました。再度お試しください。\n' + responese.msg);
                                }else{
                                    alert('システムエラーが発生しました。');
                                }
                            }).fail(function( responese ) {
                                alert('システムエラーが発生しました。');
                                // フレームワークによってはサーバーエラーをjsonで返してくれます。
                                var responese = $.parseJSON(jsonResponse);
                            });
                        }else{
                            // これは少しわからないです。申し訳ない。
                            imageURL = canvas.toDataURL();
                        }

                        // 画面にトリミング結果を出力する場合は下記が必要です。
                        // 例ではAjaxにて送信済みでので、下記機能に特に意味がありません。(結果表示したところですでに送信済みですので。)
                        var result = document.getElementById('result');
                        var roundedImage;
                        roundedCanvas = getRoundedCanvas(croppedCanvas);
                        roundedImage = document.createElement('img');
                        roundedImage.src = roundedCanvas.toDataURL()
                        roundedImage.name = 'trimed';
                        roundedImage.id = 'trimed';
                        result.innerHTML = '';
                        result.appendChild(roundedImage);
                    };
                };
            });
        });
    </script> 
</html>
<?php
    $res = array();
    try{
        // $_FILESで受け取れます。
        $file = $_FILES['blob'];
        // 画像アップロードについては割愛。
        $res = [
            'status' => 'success',
            'msg' => 'sample01',
            'obj' => $file,
        ];
    }catch(Exception $ex){
        $res = [
            'status' => 'error',
            'msg' => $ex->getMessge(),
            'obj' => null,
        ];
    }
    echo json_encode($res);
    exit();

ちょっと冗長な箇所ありますが、result部分など省けばもう少しシンプルにまとまりますね。
画像選択->トリミング->アップロードまでを簡単に行いたいときには便利ではないでしょうか。

課題

  • Cropperの再読み込み(画像間違えちゃったー)のパターンからの復帰方法
    • これは公式ページを見れば改善可能ですね。
  • toBlob関数のブラウザ依存
    • 3通り書きましたがtoBlobとmsToBlob以外にあるのか?しかもすべてに当てはまらない場合どのようにアップすればいいかわかりませんでした。知識のある方ご教示いただきたい。

画像トリミングの重要性

最後になりますが、画像トリミング、かなり重要だと思うんですよね。
WEBサービスだと画像アップしてもトリミングさせてくれないサービスが多いと思います。
セキュリティなども重要ですが、ユーザビリティも重要ですよね。
有名どころであれば(Google,Twitter,GitHub……)色々なアカウントサービスではアイコンをトリミングさせてくれますが、
ぶっちゃけ小さな会社のサービスとか含めてしまいますとどうなのでしょうか。
こういったコンテンツ編集系の処理はあまり得意としませんが、Youtube等の台頭で動画の編集もWEBやるのが当たり前になるかもしれませんね。(Twitterとかそうだし、もうなっているのか?)

ネイティブで組まなきゃーってなっていたところがWEBに喰われている感がある今日のこの頃です。
何か指摘事項ありましたら是非ともお願いいたします。
何分ぺーぺーなもので、指摘されるために書いたまであります。

参考

のん

名刺 : About me.

YouTube : のんラボ

Twitter : @nonz250

Github : nonz250

Qiita : @nonz250

My Qiita posts My Qiita contributions My Qiita followers

主にPHPを使用し、サーバーサイドを担当。最近はフロントにも興味津々。

なにかを作ったりいじったりするのが好きで、個人開発なども行っている。

趣味はバイクアイコン画像は大抵愛車の「Z250」である。友達にアイコン描いてもらえて嬉しい。

PHP / Laravel / CakePHP2 / CakePHP3 / Vue / Nuxt / C# / etc...

Tags

#のんラボ #Laravel #Vue #個人開発 #ブログ #プログラミング #javascript #Html5 #WEBサービス #Twitter #今年の抱負メーカー #勉強方法 #PWA #モバイルアプリ #Android #ツーリング #バイクに乗るエンジニア #Z250 #秋吉台 #能登半島 #バイク #冒険 #東尋坊 #Squid #リバースプロキシ #hosts #axios #cropper #AdSense #Bootstrap #MySQL #高速化 #トドTask #Telescope #デバッグ #composer #テスト #セキュリティ #POSレジ #スマレジ #本部機能 #バリデーション #入力チェック #Mac #Chrome #テスト駆動開発 #開発手法 #UI #デザイン #WEBサイト #機能美 #PHP #Laravel 6 #コメント #バージョンアップ #vue-cli #localhost #BIツール #売上分析 #TANAX #MFK250 #ツアーシェルケース2 #RESTful #API #REST API #実務的 #PHP Tech Tutor #Smaregi Tech Talk #勉強会 # ブログ #CakePHP3 #CSRF #VSCode #開発環境 #CakePHP3.0 #さくらのレンタルサーバー #モジュールモード #シェル #メール #Gmail #relay #OGP #エラーページ #抱負 #家庭教師 #ドメイン駆動設計 #DDD #読書会 #那智の滝 #伊勢志摩 #伊勢志摩スカイライン #フロント #三方五湖 #レインボーライン #ボーイスカウト・ルール #プログラマが知るべき97のこと #リファクタリング #ユビキタス言語 #車輪の再発明 #マイクロサービス #デプロイ #QA #laravel-mix #Tips #storybook #@storybook/addon-actions #昇降デスク #コードレス #書斎 #オフィス #リモートワーク #働き方 #エラーハンドリング #スマレジ4 #pixel 5 #レビュー #スマレコ #TDD #RSS #404 #高山ダム #ラーツー #React #Nuxt #node_modules #エラー #インポート #設定方法 #環境構築 #Docker #フォレストパーク神野山 #学生向け #PR #採用 #Node.js #npm #しまなみ海道 #youtube #CSS #IE #SLA #Rust #千里浜なぎさドライブウェイ #千里浜 #インサイドセールス #曽爾高原 #無線LAN #ポートフォリオ #バルカンS #納車 #Next.js #チームビルディング #リーダー #悩み #Github Actions #Marp #ネタ #サーバー移行 #S3 #ストレージサーバー #RFC 7807 #Digest #認証 #Xdebug #CLI #西日本 #例外