JavaScriptで画像をリサイズしてから画像をアップロードする

# 概要

canvasで画像を作ることが出来ることは知っていたので
サーバー側で画像のリサイズを行わないでJavaScriptでリサイズしてから画像を送信する機能を作ってみようと思い、作成してみました。

# ポイント

input type file の 値はjavascriptで上書き出来ないため、設定したファイルをリサイズしたものに変更して送信するためにAjaxを使用しています。

# クライアントサイド

```html
<!DOCTYPE html>
<html>
<head>
<script>
var scaleSlider = null;
var scaleValue = null;
var canvas = null;
var context = null;
var image = null;
var fixFileObject = null;
var scale = null;
var messageArea = null;
var csrf_token_name = "{$csrf_token_name}";
var csrf_hash = "{$csrf_hash}";

document.addEventListener("DOMContentLoaded", function() {
    canvas = document.getElementById("thumbnail");
    context = canvas.getContext("2d");

    scaleSlider = document.getElementById("scale");
    scaleValue = document.getElementById("scale-value");

    messageArea = document.getElementById("message")

    scaleSlider.addEventListener("change", changeScale);

    setFileEventListenner();
});

function changeScale() {
    scaleValue.value = scaleSlider.value;
    drawCanvas();
}

function setFileEventListenner() {
    document.getElementById("file-selecter").addEventListener("change", createPreview);
}

function createPreview(event) {

    fixFileObject = null;

    var fileObject = event.target.files[0];

    if (typeof fileObject === "undefined") {
        return;
    }

    if (fileObject.type.match(/^image\/(jpeg|png)$/) === null) {
        // jpegとpng以外の場合はクリアして終了
        var fileArea = document.getElementById("file-input");
        fileArea.innerHTML = fileArea.innerHTML;
        setFileEventListenner();
        return;
    }

    fixFileObject = fileObject;

    image = new Image();

    var reader = new FileReader();

    reader.onload = (function(fileObject) {
        return function(event) {
            image.src = event.target.result;// base64
        };
    })(fileObject);

    image.onload = function() {
        drawCanvas();
    }

    reader.readAsDataURL(fileObject);
}

function drawCanvas() {
    scale = scaleSlider.value;
    if (image !== null) {
        var imageWidth = parseInt(image.width * scale, 10);
        var imageHeight = parseInt(image.height * scale, 10);
        canvas.width = imageWidth;
        canvas.height = imageHeight;
        context.clearRect(0, 0, imageWidth, imageHeight);
        context.drawImage(image, 0, 0, imageWidth, imageHeight);
    }
}

function submitResizeFile() {
    if (image !== null && fixFileObject !== null) {

        var resizeFileObject = null;

        if (scale !== 1) {
            // サイズ変更があった場合だけ送信用ファイルを作成
            var image64Data = canvas.toDataURL(fixFileObject.type);
            image64Data = image64Data.split(',')[1];
            imageData = atob(image64Data);
            var unit8Array = new Uint8Array(imageData.length);
            unit8Array.forEach(function(element, index) {
                unit8Array[index] = imageData.charCodeAt(index);
            });
            resizeFileObject = new File(
                [unit8Array],
                fixFileObject.name,
                {
                    type: fixFileObject.type
                }
            );
        } else {
            // サイズ変更がない場合はそのまま
            resizeFileObject = fixFileObject;
        }

        var formData = new FormData();
        formData.append(csrf_token_name, csrf_hash);
        formData.append("file", resizeFileObject);

        // input type file の 値はjavascriptで上書き出来ないのでajaxで送信する
        var xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function() {
            if (xhr.readyState === XMLHttpRequest.DONE) {
                if (xhr.status === 200) {
                    csrf_hash = JSON.parse(xhr.response).csrf_hash;
                    messageArea.innerHTML = '<span style="color: green;">ファイルの送信に成功しました。</span>';
                } else {
                    messageArea.innerHTML = '<span style="color: red;">ファイルの送信に失敗しました。</span>';
                }
            }
        }
        xhr.open("POST", "/root/execute_upload");
        xhr.send(formData);
    }
}
</script>
</head>
<body>
<form id="form">
    Scale : <input id="scale" type="range" step="0.1" min="0.1" max="1" value="1"><input disabled id="scale-value" type="text" value="1" style="width: 2rem;">
    <div id="file-input"><input id="file-selecter" type="file" name="file" accept="image/jpeg, image/png"></div>
    <div><canvas id="thumbnail" width="10" height="10"></canvas></div>
    <input type="button" value="送信" onclick="submitResizeFile()">
    <div id="message"></div>
</form>
</body>
</html>
```

サーバーサイドは無いので失敗しますが、以下のような感じになります。
(CSRFの処理はコメントアウトしています。)
https://jsfiddle.net/bfb4b39z/2/


# サーバーサイド（ファイル受け取り部分 CodeIgniterのコントローラ）

```php
public function execute_upload()
{
    $data = [];
    $data['csrf_token_name'] = $this->security->get_csrf_token_name();
    $data['csrf_hash'] = $this->security->get_csrf_hash();

    try
    {
        if ( ! empty($_FILES))
        {
            // ファイルは複数ではない
            if (count($_FILES['file']['name']) > 1)
            {
                // error処理
                throw new RuntimeException('ファイルが複数設定されています。');
            }

            // エラーがない
            switch ($_FILES['file']['error'])
            {
                case UPLOAD_ERR_OK:
                    break;
                case UPLOAD_ERR_INI_SIZE:
                case UPLOAD_ERR_FORM_SIZE:
                    throw new RuntimeException('ファイルのサイズが大きるためアップロードされませんでした。');
                case UPLOAD_ERR_PARTIAL:
                    throw new RuntimeException('ファイルは完全にアップロードされませんでした。');
                case UPLOAD_ERR_NO_FILE:
                    throw new RuntimeException('ファイルはアップロードされませんでした。');
                case UPLOAD_ERR_NO_TMP_DIR:
                    throw new RuntimeException('テンポラリフォルダがありません。');
                case UPLOAD_ERR_CANT_WRITE:
                    throw new RuntimeException('ディスクへの書き込みに失敗しました。');
                case UPLOAD_ERR_EXTENSION:
                    throw new RuntimeException('異常が発生したためアップロードに失敗しました。');
            }

            // MIMEチェック
            $mime_type = mime_content_type($_FILES['file']['tmp_name']);
            $match_result = preg_match('/^image\/(jpeg|png)$/', $mime_type);
            if ($match_result === 0 || $match_result === false)
            {
                throw new RuntimeException('許可されていないファイル形式です。');
            }

            $uploaddir = APPPATH . '../public/uploads/';
            $uploadfile = $uploaddir . basename($_FILES['file']['name']);

            if (move_uploaded_file($_FILES['file']['tmp_name'], $uploadfile) === false) {
                throw new RuntimeException('ファイルのアップロードに失敗しました。');
            }

            chmod($uploadfile, 0744);
        }
        else
        {
            throw new RuntimeException('ファイルが設定されていません');
        }
    }
    catch (RuntimeException $exception)
    {
        log_message('error', $exception);
        set_status_header(500);
    }

    header('Content-Type: application/json');
    echo json_encode($data);
}
```

Codeigniterでは「ファイルアップロードクラス」が使えるのでtry catchは以下で置き換えできます

```php
try
{
    // アップロード設定
    $config['upload_path'] = APPPATH . '../public/uploads/';
    $config['allowed_types'] = 'jpg|png';
    $config['file_name'] = 'images';// 拡張子を省略すると元々の拡張子で保存してくれます。
    $config['overwrite'] = true;

    $this->load->library('upload', $config);

    // アップロード
    if ( ! $this->upload->do_upload('file'))
    {
        throw new RuntimeException($this->upload->display_errors());
    }
}
catch (RuntimeException $exception)
{
    log_message('error', $exception);
    set_status_header(500);
}
```

# メモ

ファイルが複数で指定された場合（name="file[]" multiple="multiple"で複数送信した場合）
var_dump($_FILES);は以下のようになります。

```
array(1) { 
    ["file"]=> array(5) {
        ["name"]=> array(2) {
            [0]=> string(12) "test.jpg"
            [1]=> string(18) "test2.png"
        }
        ["type"]=> array(2) {
            [0]=> string(10) "image/jpeg"
            [1]=> string(9) "image/png"
        }
        ["tmp_name"]=> array(2) {
            [0]=> string(14) "/tmp/phpwnzE0w"
            [1]=> string(14) "/tmp/phpXa5NLJ"
        }
        ["error"]=> array(2) {
            [0]=> int(0)
            [1]=> int(0)
        }
        ["size"]=> array(2) {
            [0]=> int(9357)
            [1]=> int(35964)
        }
    }
}
```

# 参考

ファイルを参照させサムネイルを出す
https://www.html5rocks.com/ja/tutorials/file/dndfiles/

キャンバスから画像を作り出す
http://www.pori2.net/html5/Canvas/150.html

base64をファイルオブジェクトにする
https://stackoverflow.com/questions/16968945/convert-base64-png-data-to-javascript-file-objects

xhr
https://stackoverflow.com/questions/3038901/how-to-get-the-response-of-xmlhttprequest

$_FILES
http://php.net/manual/ja/features.file-upload.post-method.php