k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

画像アップロード(4)IEでもドラッグ&ドロップ対応、Ajax化しつつJavaScript無効の環境にも対応

前回の記事 画像アップロード(3)ドラッグ&ドロップ対応(IE除く) の続きです。

前回のファイル入力欄を使ったドラッグ&ドロップ対応ではInternet Explolerで期待通り動作しませんでした。

IEへの対応が必須となると、通常のフォーム送信によるファイルアップロードは断念せざるを得ないでしょう。

そういうわけで、File APIはこれまではCanvasによるプレビュー画像生成時の受け渡しにのみ利用していましたが、今回はドラッグ&ドロップからのファイル送信のために利用してみます。

また今回は、ブラウザの設定でJavaScriptをOFFにされている環境でも、通常のファイル選択フォームとして動作することを目標としました。

動作確認した環境は以下の通りです。

サーバ側コード (PHP)

サーバ側のPHPコードではこれまでと同様、自作ライブラリの Volcanus_FileUploader を使います。

今回はJavaScriptがOFFの場合はこれまで通りのフォーム送信ですが、ONの場合はAjaxで送信されるとして、いくつか処理を追加しました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * Ajax化
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;

$form = [];

$form['allowableType'] = 'jpg,png';
$form['maxWidth'] = '400';
$form['maxHeight'] = '400';
$form['maxFilesize'] = '1M';
$form['maxFilesizeAsByte'] = '1048576';
$form['acceptType'] = implode(',', array_map(function($type) {
    switch($type) {
    case 'jpg':
    case 'jpeg':
        return image_type_to_mime_type(IMAGETYPE_JPEG);
    case 'gif':
        return image_type_to_mime_type(IMAGETYPE_GIF);
    case 'png':
        return image_type_to_mime_type(IMAGETYPE_PNG);
    }
}, explode(',', $form['allowableType'])));

$form['files'] = [
    'image_1' => ['label' => '画像1'],
    'image_2' => ['label' => '画像2'],
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => $form['allowableType'],
        'maxWidth' => $form['maxWidth'],
        'maxHeight' => $form['maxHeight'],
        'maxFilesize' => $form['maxFilesize'],
        'throwExceptionOnValidate' => false,
    ]);

    $validFiles = [];
    $invalidFiles = [];

    foreach ($form['files'] as $name => $file) {

        if (isset($_FILES[$name]['size']) && $_FILES[$name]['size'] > 0) {

            $uploadedFile = new NativeFile($_FILES[$name]);

            try {

                if ($uploader->validate($uploadedFile, $validator)) {
                    $validFiles[$name] = $uploadedFile;
                } else {
                    if ($validator->hasError('notFound')) {
                        $form['files'][$name]['errors'][] = sprintf('%sはアップロードされていません。', $file['label']);
                    }
                    if ($validator->hasError('filename')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイル名が不正です。', $file['label']);
                    }
                    if ($validator->hasError('filesize')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルサイズが%sバイトを超えています。', $file['label'], $validator->config('maxFilesize'));
                    }
                    if ($validator->hasError('extension')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのアップロード可能なファイルは%sです。', $file['label'], $validator->config('allowableType'));
                    }
                    if ($validator->hasError('imageType')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルタイプが拡張子と一致しません。', $file['label']);
                    }
                    if ($validator->hasError('imageWidth')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの横幅が%spxを超えています。', $file['label'], $validator->config('maxWidth'));
                    }
                    if ($validator->hasError('imageHeight')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの高さが%spxを超えています。', $file['label'], $validator->config('maxHeight'));
                    }
                    $invalidFiles[$name] = $uploadedFile;
                }

            } catch (\Exception $e) {
                $invalidFiles[$name] = $uploadedFile;
                $form['files'][$name]['errors'][] = sprintf('%sのアップロードに失敗しました。', $file['label']);
            }

        }

    }

    if (empty($invalidFiles) && !empty($validFiles)) {

        foreach ($validFiles as $name => $uploadedFile) {

            $form['files'][$name]['name'] = $uploadedFile->getClientFilename();
            $form['files'][$name]['size'] = $uploadedFile->getSize();
            $form['files'][$name]['type'] = $uploadedFile->getMimeType();
            $form['files'][$name]['dataUri'] = $uploadedFile->getContentAsDataUri();

            if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                $form['files'][$name]['width'] = $width;
                $form['files'][$name]['height'] = $height;
            }

            $movedPath = $uploader->move($uploadedFile);
            $form['files'][$name]['moved'] = [];
            $form['files'][$name]['moved']['path'] = $movedPath;
            $form['files'][$name]['moved']['name'] = basename($movedPath);
        }

    }

    if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&  $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {

        header('Content-Type: application/json; charset=UTF-8');
        header('X-Content-Type-Options: nosniff');

        if (!empty($invalidFiles)) {
            header('HTTP/1.1 400 Bad Request');
        } else {
            header('HTTP/1.1 200 OK');
        }

        echo json_encode(['form' => $form], JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS);
        exit;

    }

}
include __DIR__ . '/template.php';

jQueryAjax通信では X-Requested-With リクエストヘッダに XMLHttpRequest という値をセットしてきますので、サーバ側では $_SERVER['HTTP_X_REQUESTED_WITH'] を見てAjaxかどうかを判断、Ajaxリクエストに対してはレスポンスをJSONで返しています。

実際には json_encode() 時には json_last_error() を見てエラーであればエラー用のレスポンスを返したりしますが、今回は省略してます。(そもそもエラーや例外処理も省略してますし…あくまで画像アップロード処理の例示のためのコードということで)

送信されたファイルのバリデーション結果にエラーがあれば、ステータス 400 を返します。(クライアントの入力に起因するエラーなので、400を返す方が良いとの判断です)

レスポンスのJSONは、バリデーション結果に関わらずそのまま返しています。

<?php
$form['files'][$name]['name'] = $uploadedFile->getClientFilename();
$form['files'][$name]['size'] = $uploadedFile->getSize();
$form['files'][$name]['type'] = $uploadedFile->getMimeType();
$form['files'][$name]['width'] = $width;
$form['files'][$name]['height'] = $height;

なお、ここの連想配列のキーを以前のコードから変えたのは、表示側のJavaScriptで手抜きをするための布石です。

アップローダのテンプレート (PHP)

画面のPHPテンプレートとCSS定義はこんな感じです。

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>Volcanus_CanvasResizer.js + Volcanus_FileUploaderサンプル</title>
<style type="text/css">
.error {
    color: #f00;
    font-weight: bold;
}
.uploader{
    width: 420px;
    margin: 5px;
    float: left;
}
.droparea {
    width: 410px;
    height: 410px;
    border: 1px solid #999;
    background-color: #eee;
    display: table;
}
.droparea .thumbnail {
    width: 100%;
    display: table-cell;
    vertical-align: middle;
    text-align: center;
}
.hidden {
    display: none;
}
.form-footer{
    clear: both;
}
.btn {
    text-decoration: none;
    text-align: center;
    background: #eee;
    border: 1px solid #ddd;
    border-radius: 4px;
    line-height: 1.5em;
    color: #111;
}
.btn:hover {
    background: #ddd;
    border: 1px solid #eee;
}
.btn:active {
    background: #666;
    color: #fff;
}
.btn-sm {
    width: 80px;
    padding: 4px 0;
}
.btn-lg {
    width: 160px;
    padding: 10px 0;
}
.btn-ok {
    background: linear-gradient(#3cc, #39c);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:hover {
    background: linear-gradient(#6cf, #3cf);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:active {
    background: #3cc;
    color: #111;
}
</style>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data" action="/canvas-resizer/4/">
<input type="hidden" id="MAX_FILESIZE" value="<?=htmlspecialchars($form['maxFilesizeAsByte'])?>" />
<input type="hidden" id="MAX_WIDTH" value="<?=htmlspecialchars($form['maxWidth'])?>" />
<input type="hidden" id="MAX_HEIGHT" value="<?=htmlspecialchars($form['maxHeight'])?>" />
<input type="hidden" id="ALLOWABLE_TYPE" value="<?=htmlspecialchars($form['allowableType'])?>" />

<?php foreach ($form['files'] as $name => $file) : ?>

    <div class="uploader">
        <h2><?=htmlspecialchars($file['label'])?></h2>
        <p>
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" />
            <input type="text" class="filename hidden" />
            <button type="button" name="doSelect" class="btn btn-sm hidden">選択</button>
            <button type="button" name="doReset" class="btn btn-sm hidden">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <div class="droparea<?php if (!isset($file['dataUri'])) : ?> hidden<?php endif ?>">
            <div class="thumbnail">
<?php if (isset($file['dataUri'])) : ?>
                <img src="<?=htmlspecialchars($file['dataUri'])?>" />
<?php endif ?>
            </div>
        </div>
        <div class="fileinfo">
<?php if (isset($file['name'])) : ?>
            <h3><?=htmlspecialchars($file['name'])?></h3>
<?php endif ?>
            <ul>
<?php if (isset($file['size'])) : ?>
                <li>ファイルサイズ:<?=htmlspecialchars($file['size'])?></li>
<?php endif ?>
<?php if (isset($file['type'])) : ?>
                <li>ファイル種別:<?=htmlspecialchars($file['type'])?></li>
<?php endif ?>
<?php if (isset($file['width'])) : ?>
                <li>横幅:<?=htmlspecialchars($file['width'])?></li>
<?php endif ?>
<?php if (isset($file['height'])) : ?>
                <li>高さ:<?=htmlspecialchars($file['height'])?></li>
<?php endif ?>
<?php if (isset($file['moved']['path'])) : ?>
                <li>配置先:<?=htmlspecialchars($file['moved']['path'])?></li>
<?php endif ?>
            </ul>
        </div>
        <ul class="uploading-error">
<?php if (isset($file['errors'])) : ?>
<?php foreach ($file['errors'] as $error) : ?>
            <li class="error"><?=htmlspecialchars($error)?></li>
<?php endforeach ?>
<?php endif ?>
        </ul>
    </div>

<?php endforeach ?>

    <p class="form-footer">
        <input type="submit" value="送信する" class="btn btn-lg btn-ok" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript" src="uploader.js"></script>

</body>
</html>

前回の input[type="file"] 巨大化&透明化をやめたほか、ファイル入力欄を初期状態で表示させる代わりに、JavaScriptのみで利用する物は初期状態で非表示としています。(class="hidden" が設定されている要素を非表示)

JavaScript無効の環境ではこういう風に見えます。

ダミーのテキスト入力欄やボタン、サムネイル(ドラッグ&ドロップ)領域が表示されません。

バリデーションはサーバ側のみになります。エラー後にファイル入力欄の選択状態を復元できないのは困りものですね…。

送信後の戻りでサムネイル(ドラッグ&ドロップ)領域が表示され、サーバが返したDataURIが画像として表示されます。

アップローダJavaScript

単純な機能なのに、JavaScriptが結構長くなってしまいました。後で部分ごとに見ていきますが、とりあえず全部。

uploader.js

$(function() {

    $('.uploader .hidden').removeClass('hidden');
    $('.uploader input[type="file"]').addClass('hidden');

    var MAX_FILESIZE = $('#MAX_FILESIZE').val();
    var MAX_WIDTH = $('#MAX_WIDTH').val();
    var MAX_HEIGHT = $('#MAX_HEIGHT').val();
    var ALLOWABLE_TYPE = $('#ALLOWABLE_TYPE').val();

    var filesToSend = {};

    var clearFile = function(uploader) {
        var fileId = $(uploader).find('input[type="file"]').attr('name');
        if (typeof filesToSend[fileId] !== 'undefined') {
            delete filesToSend[fileId];
        }
        $(uploader).find('input[type="file"]').val('');
        $(uploader).find('input.filename').val('');
        $(uploader).find('.thumbnail').html('');
        $(uploader).find('.fileinfo').html('');
    };

    var clearErrors = function(uploader) {
        $(uploader).find('.uploading-error').html('');
    };

    var appendError = function(uploader, message) {
        $(uploader).find('.uploading-error').append($('<li class="error">').text(message));
    };

    // バイト数を指定された小数点桁数の単位付き表示に変換
    var formatBytes = function(bytes, decimals) {
        var units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
        var number = '';
        var unit = '';
        var value = bytes;
        for (var i = 0; i < units.length; i++) {
            unit = units[i];
            number = value;
            if (value < 1024) {
                break;
            }
            value = value / 1024;
        }
        // @via http://www.jacklmoore.com/notes/rounding-in-javascript/
        return Number(Math.round(number + 'e' + decimals) + 'e-' + decimals).toString() + unit;
    };

    var createFileinfo = function(uploader, file) {
        if (typeof file.dataUri !== 'undefined') {
            $(uploader).find('.thumbnail').html($('<img>').attr('src', file.dataUri));
        }
        var ul = $('<ul>');
        if (typeof file.size !== 'undefined') {
            ul.append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)));
        }
        if (typeof file.type !== 'undefined') {
            ul.append($('<li>').text('ファイル種別:' + file.type));
        }
        if (typeof file.width !== 'undefined') {
            ul.append($('<li>').text('横幅:' + file.width));
        }
        if (typeof file.height !== 'undefined') {
            ul.append($('<li>').text('高さ:' + file.height));
        }
        if (typeof file.lastModifiedDate !== 'undefined') {
            ul.append($('<li>').text('更新日時:' + file.lastModifiedDate));
        }
        if (typeof file.moved !== 'undefined') {
            if (typeof file.moved.path !== 'undefined') {
                ul.append($('<li>').text('配置先:' + file.moved.path));
            }
        }
        $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(ul);
    };

    var preview = function(uploader, file) {
        var allowableTypes = ALLOWABLE_TYPE.split(',');
        if (allowableTypes.length > 0) {
            var found = false;
            var matches = file.name.split(/\.(?=[^.]+$)/);
            if (matches && typeof matches[1] !== 'undefined') {
                var extension = matches[1].toLowerCase();
                for (var i = 0; i < allowableTypes.length; i++) {
                    if (extension === allowableTypes[i]) {
                        found = true;
                        break;
                    }
                    if (allowableTypes[i] === 'jpg' && (extension === 'jpeg' || extension === 'jpg')) {
                        found = true;
                        break;
                    }
                }
            }
            if (!found) {
                appendError(uploader, 'ファイル拡張子が' + ALLOWABLE_TYPE + 'ではありません');
                clearFile(uploader);
                return;
            }
        }
        if (file.size > parseInt(MAX_FILESIZE, 10)) {
            appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
            clearFile(uploader);
            return;
        }
        if (file.type.substring(0, 6) === 'image/') {
            var fileId = $(uploader).find('input[type="file"]').attr('name');
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    createFileinfo(uploader, {
                        name: file.name,
                        size: file.size,
                        type: file.type,
                        lastModifiedDate: file.lastModifiedDate.toUTCString(),
                        width: image.width,
                        height: image.height,
                        dataUri: canvas.toDataURL('image/png')
                    });
                    if (image.width > parseInt(MAX_WIDTH, 10)) {
                        appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                    }
                    if (image.height > parseInt(MAX_HEIGHT, 10)) {
                        appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                    }
                }
            });
            $(uploader).find('input.filename').val(file.name);
            filesToSend[fileId] = file;
        }
    };

    $('.uploader input.filename').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doSelect"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doReset"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        clearFile(uploader);
    });

    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        preview(uploader, event.target.files[0]);
    });

    $('.uploader .droparea').on('click', function() {
        var uploader = $(this).parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader .droparea').on('dragover dragenter', function(event) {
        event.stopPropagation();
        event.preventDefault();
        return false;
    });

    $('.uploader .droparea').on('drop', function(event) {
        var uploader = $(this).parent()[0];
        event.stopPropagation();
        event.preventDefault();
        clearErrors(uploader);
        preview(uploader, event.originalEvent.dataTransfer.files[0]);
    });

    $('form').on('submit', function(event) {
        event.stopPropagation();
        event.preventDefault();
        var url = $(this).attr('action');
        var formData = new FormData();
        for (var fileId in filesToSend) {
            formData.append(fileId, filesToSend[fileId]);
        }
        $.ajax({
            type: 'POST',
            timeout: 30000,
            url: url,
            dataType: 'json',
            processData: false,
            contentType: false,
            data: formData
        }).done(function(data, textStatus, jqXHR) {
            update(data);
            return false;
        }).fail(function(jqXHR, textStatus, errorThrown) {
            if (jqXHR.responseText) {
                update($.parseJSON(jqXHR.responseText));
            }
            return false;
        });
    });

    var update = function(data) {
        if (typeof data.form !== 'undefined' &&
            typeof data.form.files !== 'undefined'
        ) {
            for (var key in data.form.files) {
                if ($('input[name="' + key + '"]').length) {
                    var file = data.form.files[key];
                    var uploader = $('input[name="' + key + '"]').parent().parent()[0];
                    clearErrors(uploader);
                    if (typeof file.errors !== 'undefined') {
                        for (var i = 0; i < file.errors.length; i++) {
                            appendError(uploader, file.errors[i]);
                        }
                    } else {
                        createFileinfo(uploader, file);
                    }
                }
            }
        }
    };

});

JavaScriptが有効な場合は初期表示がこういう風に変わります。

まあ、見た目は前回の画面と一緒なんですけどね。

以下、前回から変わった部分をピックアップします。

$('.uploader .hidden').removeClass('hidden');
$('.uploader input[type="file"]').addClass('hidden');

ここは初期状態で class="hidden" を指定した、JavaScript有効時のみ利用する要素を表示しています。

ファイル入力欄はドラッグ&ドロップのみにするなら不要なんですが、ファイル選択ダイアログを起動するために、要素としては存在するけど非表示にしています。

var filesToSend = {};

var clearFile = function(uploader) {
    var fileId = $(uploader).find('input[type="file"]').attr('name');
    if (typeof filesToSend[fileId] !== 'undefined') {
        delete filesToSend[fileId];
    }
    $(uploader).find('input[type="file"]').val('');
    $(uploader).find('input.filename').val('');
    $(uploader).find('.thumbnail').html('');
    $(uploader).find('.fileinfo').html('');
};

送信対象のファイルオブジェクトは filesToSend というオブジェクトに <input type="file"> のname属性値をプロパティ名としてセットしています。(PHPer脳ですみません…)

なので、「リセット」ボタンなどから呼ばれる clearFile() にはこれをクリアする処理を追加しました。delete演算子でオブジェクトのプロパティごと削除しています。

var createFileinfo = function(uploader, file) {
    if (typeof file.dataUri !== 'undefined') {
        $(uploader).find('.thumbnail').html($('<img>').attr('src', file.dataUri));
    }
    var ul = $('<ul>');
    if (typeof file.size !== 'undefined') {
        ul.append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)));
    }
    if (typeof file.type !== 'undefined') {
        ul.append($('<li>').text('ファイル種別:' + file.type));
    }
    if (typeof file.width !== 'undefined') {
        ul.append($('<li>').text('横幅:' + file.width));
    }
    if (typeof file.height !== 'undefined') {
        ul.append($('<li>').text('高さ:' + file.height));
    }
    if (typeof file.lastModifiedDate !== 'undefined') {
        ul.append($('<li>').text('更新日時:' + file.lastModifiedDate));
    }
    if (typeof file.moved !== 'undefined') {
        if (typeof file.moved.path !== 'undefined') {
            ul.append($('<li>').text('配置先:' + file.moved.path));
        }
    }
    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(ul);
};

ファイル送信のAjax化に伴い、ファイル情報の表示がファイル選択時とフォーム送信完後の両方で発生するようになるため、処理を関数で共通化しました。

(サーバ側の連想配列のキーを変更したのは、これを利用するためです)

また、画像の横幅と高さを同じオブジェクトから取得するようにしたり、細かいところを変更しています。

var preview = function(uploader, file) {
    var allowableTypes = ALLOWABLE_TYPE.split(',');
    if (allowableTypes.length > 0) {
        var found = false;
        var matches = file.name.split(/\.(?=[^.]+$)/);
        if (matches && typeof matches[1] !== 'undefined') {
            var extension = matches[1].toLowerCase();
            for (var i = 0; i < allowableTypes.length; i++) {
                if (extension === allowableTypes[i]) {
                    found = true;
                    break;
                }
                if (allowableTypes[i] === 'jpg' && (extension === 'jpeg' || extension === 'jpg')) {
                    found = true;
                    break;
                }
            }
        }
        if (!found) {
            appendError(uploader, 'ファイル拡張子が' + ALLOWABLE_TYPE + 'ではありません');
            clearFile(uploader);
            return;
        }
    }
    if (file.size > parseInt(MAX_FILESIZE, 10)) {
        appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
        clearFile(uploader);
        return;
    }
    if (file.type.substring(0, 6) === 'image/') {
        var fileId = $(uploader).find('input[type="file"]').attr('name');
        var thumbnail = Volcanus_CanvasResizer.create({
            file: file
            ,maxWidth: 400
            ,maxHeight: 400
            ,onLoad: function(image, canvas) {
                createFileinfo(uploader, {
                    name: file.name,
                    size: file.size,
                    type: file.type,
                    lastModifiedDate: file.lastModifiedDate.toUTCString(),
                    width: image.width,
                    height: image.height,
                    dataUri: canvas.toDataURL('image/png')
                });
                if (image.width > parseInt(MAX_WIDTH, 10)) {
                    appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                }
                if (image.height > parseInt(MAX_HEIGHT, 10)) {
                    appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                }
            }
        });
        $(uploader).find('input.filename').val(file.name);
        filesToSend[fileId] = file;
    }
};

ドラッグ&ドロップやファイル選択から呼ばれるプレビューの処理です。

前述のファイル情報表示用の要素を生成する関数を呼んでいますが、処理の共通化のため新しいオブジェクトに詰め替えています。

また、送信対象のファイルオブジェクトを filesToSend というオブジェクトにセットしています。

    $('.uploader .droparea').on('click', function() {
        var uploader = $(this).parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader .droparea').on('dragover dragenter', function(event) {
        event.stopPropagation();
        event.preventDefault();
        return false;
    });

    $('.uploader .droparea').on('drop', function(event) {
        var uploader = $(this).parent()[0];
        event.stopPropagation();
        event.preventDefault();
        clearErrors(uploader);
        preview(uploader, event.originalEvent.dataTransfer.files[0]);
    });

ドラッグ&ドロップ領域へのハンドラ定義です。

クリックするとファイル選択ダイアログを開き、ドラッグするとデフォルトイベントを無効化し、ドロップするとエラーメッセージを消した上で前述のプレビュー処理を呼んでいます。

イベント処理のコードは Web アプリケーションからファイルを扱う | MDN を参考にしました。

event.stopPropagation()event.preventDefault()jQueryのイベントオブジェクトでも有効ですが、ドラッグ&ドロップでセットしたファイルが格納されている dataTransfer にはアクセスできないため、event.originalEvent.dataTransfer として素のイベントオブジェクトにアクセスしています。

参考

    $('form').on('submit', function(event) {
        event.stopPropagation();
        event.preventDefault();
        var url = $(this).attr('action');
        var formData = new FormData();
        for (var fileId in filesToSend) {
            formData.append(fileId, filesToSend[fileId]);
        }
        $.ajax({
            type: 'POST',
            timeout: 30000,
            url: url,
            dataType: 'json',
            processData: false,
            contentType: false,
            data: formData
        }).done(function(data, textStatus, jqXHR) {
            update(data);
            return false;
        }).fail(function(jqXHR, textStatus, errorThrown) {
            if (jqXHR.responseText) {
                update($.parseJSON(jqXHR.responseText));
            }
            return false;
        });
    });

    var update = function(data) {
        if (typeof data.form !== 'undefined' &&
            typeof data.form.files !== 'undefined'
        ) {
            for (var key in data.form.files) {
                if ($('input[name="' + key + '"]').length) {
                    var file = data.form.files[key];
                    var uploader = $('input[name="' + key + '"]').parent().parent()[0];
                    clearErrors(uploader);
                    if (typeof file.errors !== 'undefined') {
                        for (var i = 0; i < file.errors.length; i++) {
                            appendError(uploader, file.errors[i]);
                        }
                    } else {
                        createFileinfo(uploader, file);
                    }
                }
            }
        }
    };

最後にフォーム送信時のイベントハンドラ定義と、送信結果としてJSONを受け取った後の処理です。

デフォルトのイベント(つまりフォーム送信)を無効化した後、FormDataを使って送信対象のファイルオブジェクトをAjaxで送信します。

FormDataによるファイル送信については以下の記事が参考になります。

サーバ側ではファイル受付時とバリデーションエラー時ともに form.files というオブジェクトのリストをJSONで返していますので、jQuery.ajax() の結果に関わらず共通の処理を呼ぶようにしました。

また、サーバ側ではバリデーションエラーに対してステータス400を返していますので、バリデーションエラー時は通信に成功した場合でも失敗時のハンドラが呼ばれることになるわけですが、失敗時のレスポンスはdataTypeの指定に関わらず明示的にパースする必要があるため、 $.parseJSON(jqXHR.responseText) としています。

動作確認

クライアント側バリデーションのエラー時はこんな感じ。

クライアント側エラーを無視して送信すると、サーバ側バリデーションでエラーが返されます。

見た目は以前と同じですが、Ajax化しているので画面がリロードされません。ということは、JavaScriptをOFFにした時のように、ファイル入力欄の選択状態がクリアされません。やはり、こちらの方が使いやすいですね。

ドラッグ&ドロップして…

送信結果です。成功でも何もそれっぽいメッセージを表示していないので、ちょっと分かりづらいですが…。

今回は省略しましたが、他にもAjax化した場合は、送信中に再送信できないようにボタンを無効化したり、色々と面倒な処理が必要になってくると思います。

いわゆるシングルページアプリケーションとか、jQueryだけではちょっと作るのはしんどそうですね。(jQueryしか使えませんが…)

次はいよいよ確認画面の実装でしょうか。

画像アップロード(3)ドラッグ&ドロップ対応(IE除く)

前回の記事 画像アップロード(2)クライアント側で簡易バリデーション、複数ファイル対応 の続きです。

今回は前回以上にトリッキーな手法を使って、ドラッグ&ドロップに対応してみます。

ただしこの方法は、InternetExplolerでは現時点で最新の11でも期待通りに動作しません。

動作確認した環境は以下の通りです。

サーバ側コード (PHP)

サーバ側のPHPコードでは前回と同様、自作ライブラリの Volcanus_FileUploader を使います。というか、今回は基本的にクライアント側の変更のみで対応しますので、前回と全く同じコードですが、一応掲載しておきます。

※2015-03-19追記:Volcanus_FileUploaderの更新に伴い、一部コードを書き換えました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * ドラッグ&ドロップ対応 (IE非対応)
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;

$form = [];

$form['allowableType'] = 'jpg,png';
$form['maxWidth'] = '400';
$form['maxHeight'] = '400';
$form['maxFilesize'] = '1M';
$form['maxFilesizeAsByte'] = '1048576';
$form['acceptType'] = implode(',', array_map(function($type) {
    switch($type) {
    case 'jpg':
    case 'jpeg':
        return image_type_to_mime_type(IMAGETYPE_JPEG);
    case 'gif':
        return image_type_to_mime_type(IMAGETYPE_GIF);
    case 'png':
        return image_type_to_mime_type(IMAGETYPE_PNG);
    }
}, explode(',', $form['allowableType'])));

$form['files'] = [
    'image_1' => ['label' => '画像1'],
    'image_2' => ['label' => '画像2'],
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => $form['allowableType'],
        'maxWidth' => $form['maxWidth'],
        'maxHeight' => $form['maxHeight'],
        'maxFilesize' => $form['maxFilesize'],
        'throwExceptionOnValidate' => false,
    ]);

    $validFiles = [];
    $invalidFiles = [];

    foreach ($form['files'] as $name => $file) {

        if (isset($_FILES[$name]['size']) && $_FILES[$name]['size'] > 0) {

            $uploadedFile = new NativeFile($_FILES[$name]);

            try {

                if ($uploader->validate($uploadedFile, $validator)) {
                    $validFiles[$name] = $uploadedFile;
                } else {
                    if ($validator->hasError('notFound')) {
                        $form['files'][$name]['errors'][] = sprintf('%sはアップロードされていません。', $file['label']);
                    }
                    if ($validator->hasError('filename')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイル名が不正です。', $file['label']);
                    }
                    if ($validator->hasError('filesize')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルサイズが%sバイトを超えています。', $file['label'], $validator->config('maxFilesize'));
                    }
                    if ($validator->hasError('extension')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのアップロード可能なファイルは%sです。', $file['label'], $validator->config('allowableType'));
                    }
                    if ($validator->hasError('imageType')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルタイプが拡張子と一致しません。', $file['label']);
                    }
                    if ($validator->hasError('imageWidth')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの横幅が%spxを超えています。', $file['label'], $validator->config('maxWidth'));
                    }
                    if ($validator->hasError('imageHeight')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの高さが%spxを超えています。', $file['label'], $validator->config('maxHeight'));
                    }
                    $invalidFiles[$name] = $uploadedFile;
                }

            } catch (\Exception $e) {
                $invalidFiles[$name] = $uploadedFile;
                $form['files'][$name]['errors'][] = sprintf('%sのアップロードに失敗しました。', $file['label']);
            }

        }

    }

    if (empty($invalidFiles) && !empty($validFiles)) {

        foreach ($validFiles as $name => $uploadedFile) {

            $form['files'][$name]['name'] = $uploadedFile->getClientFilename();
            $form['files'][$name]['size'] = $uploadedFile->getSize();
            $form['files'][$name]['type'] = $uploadedFile->getMimeType();
            $form['files'][$name]['dataUri'] = $uploadedFile->getContentAsDataUri();

            if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                $form['files'][$name]['width'] = $width;
                $form['files'][$name]['height'] = $height;
            }

            $movedPath = $uploader->move($uploadedFile);
            $form['files'][$name]['moved'] = [];
            $form['files'][$name]['moved']['path'] = $movedPath;
            $form['files'][$name]['moved']['name'] = basename($movedPath);
        }

    }

}
include __DIR__ . '/template.php';

アップローダのテンプレート (PHP)

画面のPHPテンプレートはこんな感じです。

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>Volcanus_CanvasResizer.js + Volcanus_FileUploaderサンプル</title>
<style type="text/css">
.error {
    color: #f00;
    font-weight: bold;
}
.uploader{
    width: 420px;
    margin: 5px;
    float: left;
}
.uploader input[type="file"] {
    width: 412px;
    height: 412px;
    opacity: 0;
    position: absolute;
    z-index: 1;
}
.droparea {
    width: 410px;
    height: 410px;
    border: 1px solid #999;
    background-color: #eee;
    display: table;
}
.droparea .thumbnail {
    width: 100%;
    display: table-cell;
    vertical-align: middle;
    text-align: center;
}
.form-footer{
    clear: both;
}
.btn {
    text-decoration: none;
    text-align: center;
    background: #eee;
    border: 1px solid #ddd;
    border-radius: 4px;
    line-height: 1.5em;
    color: #111;
}
.btn:hover {
    background: #ddd;
    border: 1px solid #eee;
}
.btn:active {
    background: #666;
    color: #fff;
}
.btn-sm {
    width: 80px;
    padding: 4px 0;
}
.btn-lg {
    width: 160px;
    padding: 10px 0;
}
.btn-ok {
    background: linear-gradient(#3cc, #39c);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:hover {
    background: linear-gradient(#6cf, #3cf);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:active {
    background: #3cc;
    color: #111;
}
</style>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data" action="/canvas-resizer/3/">
<input type="hidden" id="MAX_FILESIZE" value="<?=htmlspecialchars($form['maxFilesizeAsByte'])?>" />
<input type="hidden" id="MAX_WIDTH" value="<?=htmlspecialchars($form['maxWidth'])?>" />
<input type="hidden" id="MAX_HEIGHT" value="<?=htmlspecialchars($form['maxHeight'])?>" />
<input type="hidden" id="ALLOWABLE_TYPE" value="<?=htmlspecialchars($form['allowableType'])?>" />

<?php foreach ($form['files'] as $name => $file) : ?>

    <div class="uploader">
        <h2><?=htmlspecialchars($file['label'])?></h2>
        <p>
            <input type="text" class="filename" />
            <button type="button" name="doSelect" class="btn btn-sm">選択</button>
            <button type="button" name="doReset" class="btn btn-sm">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <p>
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" />
        </p>
        <div class="droparea">
            <div class="thumbnail">
<?php if (isset($file['dataUri'])) : ?>
                <img src="<?=htmlspecialchars($file['dataUri'])?>" />
<?php endif ?>
            </div>
        </div>
        <div class="fileinfo">
<?php if (isset($file['name'])) : ?>
            <h3><?=htmlspecialchars($file['name'])?></h3>
<?php endif ?>
            <ul>
<?php if (isset($file['size'])) : ?>
                <li>ファイルサイズ:<?=htmlspecialchars($file['size'])?></li>
<?php endif ?>
<?php if (isset($file['type'])) : ?>
                <li>ファイル種別:<?=htmlspecialchars($file['type'])?></li>
<?php endif ?>
<?php if (isset($file['width'])) : ?>
                <li>横幅:<?=htmlspecialchars($file['width'])?></li>
<?php endif ?>
<?php if (isset($file['height'])) : ?>
                <li>高さ:<?=htmlspecialchars($file['height'])?></li>
<?php endif ?>
<?php if (isset($file['moved']['path'])) : ?>
                <li>配置先:<?=htmlspecialchars($file['moved']['path'])?></li>
<?php endif ?>
            </ul>
        </div>
        <ul class="uploading-error">
<?php if (isset($file['errors'])) : ?>
<?php foreach ($file['errors'] as $error) : ?>
            <li class="error"><?=htmlspecialchars($error)?></li>
<?php endforeach ?>
<?php endif ?>
        </ul>
    </div>

<?php endforeach ?>

    <p class="form-footer">
        <input type="submit" value="送信する" class="btn btn-lg btn-ok" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript" src="uploader.js"></script>

</body>
</html>

今回はサムネイル表示領域の一つ上の階層に、横幅・高さとも固定値 (410px)の領域を設置しています。

更に今回はファイル入力欄を透明にして、その領域に重ねるように配置しています。

.uploader input[type="file"] {
    width: 412px;
    height: 412px;
    opacity: 0;
    position: absolute;
    z-index: 1;
}
.droparea {
    width: 410px;
    height: 410px;
    border: 1px solid #999;
    background-color: #eee;
    display: table;
}
.droparea .thumbnail {
    width: 100%;
    display: table-cell;
    vertical-align: middle;
    text-align: center;
}

ファイル入力欄の412pxの2pxは枠線のぶんだけ長く設定したものです。opacityは透明度を指定するプロパティですが、0で完全に透明になります。

position:absolute で他の要素の配置に影響を与えないようにして親のボックスに基準位置を合わせ、z-index:1 で重ねるように配置します。

サムネイル表示は親のボックスを display:table で表形式にした上で、サムネイル表示領域を display:table-cell vertical-align:middle text-align:center として中身の画像をセンタリングします。

またドラッグ&ドロップの場合、accept属性によるファイルダイアログのフィルタリングが働かなくなってしまうため、JavaScript拡張子のチェックを行うようにします。

<input type="hidden" id="ALLOWABLE_TYPE" value="<?=htmlspecialchars($form['allowableType'])?>" /> の箇所はそのために入れています。

こうしてできたフォームがこれです。

opacityプロパティとか使ったのは初めてですが、まさしくクリックジャッキングの手法ですよね、これ…。

ちなみにopacity:0.5にするとこうなりました。

重なって配置されていることが分かります。

アップローダJavaScript

JavaScriptは前回とあまり変わりませんが、前述のファイル拡張子のチェック処理を追加しています。

uploader.js

$(function() {

    var MAX_FILESIZE = $('#MAX_FILESIZE').val();
    var MAX_WIDTH = $('#MAX_WIDTH').val();
    var MAX_HEIGHT = $('#MAX_HEIGHT').val();
    var ALLOWABLE_TYPE = $('#ALLOWABLE_TYPE').val();

    var clearFile = function(uploader) {
        $(uploader).find('input[type="file"]').val('');
        $(uploader).find('input.filename').val('');
        $(uploader).find('.thumbnail').html('');
        $(uploader).find('.fileinfo').html('');
    };

    var clearErrors = function(uploader) {
        $(uploader).find('.uploading-error').html('');
    };

    var appendError = function(uploader, message) {
        $(uploader).find('.uploading-error').append($('<li class="error">').text(message));
    };

    // バイト数を指定された小数点桁数の単位付き表示に変換
    var formatBytes = function(bytes, decimals) {
        var units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
        var number = '';
        var unit = '';
        var value = bytes;
        for (var i = 0; i < units.length; i++) {
            unit = units[i];
            number = value;
            if (value < 1024) {
                break;
            }
            value = value / 1024;
        }
        // @via http://www.jacklmoore.com/notes/rounding-in-javascript/
        return Number(Math.round(number + 'e' + decimals) + 'e-' + decimals).toString() + unit;
    };

    var preview = function(uploader, file) {
        var allowableTypes = ALLOWABLE_TYPE.split(',');
        if (allowableTypes.length > 0) {
            var found = false;
            var matches = file.name.split(/\.(?=[^.]+$)/);
            if (matches && typeof matches[1] !== 'undefined') {
                var extension = matches[1].toLowerCase();
                for (var i = 0; i < allowableTypes.length; i++) {
                    if (extension === allowableTypes[i]) {
                        found = true;
                        break;
                    }
                    if (allowableTypes[i] === 'jpg' && (extension === 'jpeg' || extension === 'jpg')) {
                        found = true;
                        break;
                    }
                }
            }
            if (!found) {
                appendError(uploader, 'ファイル拡張子が' + ALLOWABLE_TYPE + 'ではありません');
                clearFile(uploader);
                return;
            }
        }
        if (file.size > parseInt(MAX_FILESIZE, 10)) {
            appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
            clearFile(uploader);
            return;
        }
        if (file.type.substring(0, 6) === 'image/') {
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html($('<img>').attr('src', canvas.toDataURL('image/png')));
                    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                    if (image.width > parseInt(MAX_WIDTH, 10)) {
                        appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                    }
                    if (image.height > parseInt(MAX_HEIGHT, 10)) {
                        appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                    }
                }
            });
            $(uploader).find('input.filename').val(file.name);
        }
    };

    $('.uploader input.filename').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doSelect"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doReset"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        clearFile(uploader);
    });

    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        preview(uploader, event.target.files[0]);
    });

});

通常、ドラッグ&ドロップでファイルを扱おうとすると dragover, dragenter に加えて drop イベントを捕捉して event.dataTransfer から files プロパティを参照します。

今回の場合、実際にはファイル入力欄に直接ドロップしている形となるため、イベント処理については何も追加する必要はありませんでした。

前回と同様、クライアント側のバリデーションでエラーになった場合はこんな感じ。メッセージの表示箇所は変えてます。

拡張子のエラー。

アップロード完了後はDataURI形式に変換した画像と移動先のパスを表示します。

今回の手法については以下のコードを参考にしました。

修正箇所は少なく済んだのですが、初めに述べた通り、InternetExplolerではファイル入力欄へのドロップが動作しないため、あまり現実的ではありません。

次はInternetExplolerでも動くように、通常のドラッグ&ドロップ対応を行おうと思います。ファイル入力欄への値のセットはセキュリティ上の理由で禁じられているようなので、かなりの変更が必要になるでしょう…。

画像アップロード(2)クライアント側で簡易バリデーション、複数ファイル対応

前回の記事 画像アップロード(1)Canvasでプレビューして普通に画像をアップロード の続きです。

今回は純正のファイル入力欄を隠してダミーのテキスト入力欄とボタンを配置することでスタイルシートによる見た目の変更に対応するとともに、クライアント側で簡易バリデーション(ファイルサイズ、横幅、高さのチェック)を行います。

コンテントタイプについてはクライアント側ではバリデーションを行わず、input要素にaccept属性を設定して、ファイルダイアログの表示対象を絞り込むようにします。

また、ファイルの入力欄を複数設けることで、複数のファイル送信に対応してみます。

動作確認した環境は以下の通りです。

サーバ側コード (PHP)

サーバ側のPHPコードです。前回と同様、自作ライブラリの Volcanus_FileUploader を使います。

※2015-03-19追記:Volcanus_FileUploaderの更新に伴い、一部コードを書き換えました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * ダミーファイルフォーム,クライアントバリデーション,accept属性,複数ファイル対応
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;

$form = [];

$form['allowableType'] = 'jpg,png';
$form['maxWidth'] = '400';
$form['maxHeight'] = '400';
$form['maxFilesize'] = '1M';
$form['maxFilesizeAsByte'] = '1048576';
$form['acceptType'] = implode(',', array_map(function($type) {
    switch($type) {
    case 'jpg':
    case 'jpeg':
        return image_type_to_mime_type(IMAGETYPE_JPEG);
    case 'gif':
        return image_type_to_mime_type(IMAGETYPE_GIF);
    case 'png':
        return image_type_to_mime_type(IMAGETYPE_PNG);
    }
}, explode(',', $form['allowableType'])));

$form['files'] = [
    'image_1' => ['label' => '画像1'],
    'image_2' => ['label' => '画像2'],
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => $form['allowableType'],
        'maxWidth' => $form['maxWidth'],
        'maxHeight' => $form['maxHeight'],
        'maxFilesize' => $form['maxFilesize'],
        'throwExceptionOnValidate' => false,
    ]);

    $validFiles = [];
    $invalidFiles = [];

    foreach ($form['files'] as $name => $file) {

        if (isset($_FILES[$name])) {

            $uploadedFile = new NativeFile($_FILES[$name]);

            try {

                if ($uploader->validate($uploadedFile, $validator)) {
                    $validFiles[$name] = $uploadedFile;
                } else {
                    if ($validator->hasError('notFound')) {
                        $form['files'][$name]['errors'][] = sprintf('%sはアップロードされていません。', $file['label']);
                    }
                    if ($validator->hasError('filename')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイル名が不正です。', $file['label']);
                    }
                    if ($validator->hasError('filesize')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルサイズが%sバイトを超えています。', $file['label'], $validator->config('maxFilesize'));
                    }
                    if ($validator->hasError('extension')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのアップロード可能なファイルは%sです。', $file['label'], $validator->config('allowableType'));
                    }
                    if ($validator->hasError('imageType')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルタイプが拡張子と一致しません。', $file['label']);
                    }
                    if ($validator->hasError('imageWidth')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの横幅が%spxを超えています。', $file['label'], $validator->config('maxWidth'));
                    }
                    if ($validator->hasError('imageHeight')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの高さが%spxを超えています。', $file['label'], $validator->config('maxHeight'));
                    }
                    $invalidFiles[$name] = $uploadedFile;
                }

            } catch (\Exception $e) {
                $invalidFiles[$name] = $uploadedFile;
                $form['files'][$name]['errors'][] = sprintf('%sのアップロードに失敗しました。', $file['label']);
            }

        }

    }

    if (empty($invalidFiles) && !empty($validFiles)) {

        foreach ($validFiles as $name => $uploadedFile) {

            $form['files'][$name]['name'] = $uploadedFile->getClientFilename();
            $form['files'][$name]['size'] = $uploadedFile->getSize();
            $form['files'][$name]['type'] = $uploadedFile->getMimeType();
            $form['files'][$name]['dataUri'] = $uploadedFile->getContentAsDataUri();

            if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                $form['files'][$name]['width'] = $width;
                $form['files'][$name]['height'] = $height;
            }

            $movedPath = $uploader->move($uploadedFile);
            $form['files'][$name]['moved'] = [];
            $form['files'][$name]['moved']['path'] = $movedPath;
            $form['files'][$name]['moved']['name'] = basename($movedPath);
        }

    }

}
include __DIR__ . '/template.php';

複数ファイルになったので、前回と同じアップロードファイルの処理がループになっているほか、諸々の制限値などをHTML上で表示するために $form 変数の中に入れています。

$form['acceptType'] = のところはinput要素のaccept属性値(カンマ区切り)を生成しています。これ指定しておくと、ファイル選択ダイアログで表示するファイルを拡張子で絞り込んでくれるという便利機能です。(元々HTML4.01の頃から仕様にはあったんですが、近年になってようやくブラウザの対応が進んだようです)

アップローダのテンプレート (PHP)

画面のPHPテンプレートはこんな感じです。

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>Volcanus_CanvasResizer.js + Volcanus_FileUploaderサンプル</title>
<style type="text/css">
.error {
    color: #f00;
    font-weight: bold;
}
.uploader{
    width: 420px;
    margin: 5px;
    float: left;
}
.form-footer{
    clear: both;
}
.btn {
    text-decoration: none;
    text-align: center;
    background: #eee;
    border: 1px solid #ddd;
    border-radius: 4px;
    line-height: 1.5em;
    color: #111;
}
.btn:hover {
    background: #ddd;
    border: 1px solid #eee;
}
.btn:active {
    background: #666;
    color: #fff;
}
.btn-sm {
    width: 80px;
    padding: 4px 0;
}
.btn-lg {
    width: 160px;
    padding: 10px 0;
}
.btn-ok {
    background: linear-gradient(#3cc, #39c);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:hover {
    background: linear-gradient(#6cf, #3cf);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:active {
    background: #3cc;
    color: #111;
}
</style>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data" action="/canvas-resizer/2/">
<input type="hidden" id="MAX_FILESIZE" value="<?=htmlspecialchars($form['maxFilesizeAsByte'])?>" />
<input type="hidden" id="MAX_WIDTH" value="<?=htmlspecialchars($form['maxWidth'])?>" />
<input type="hidden" id="MAX_HEIGHT" value="<?=htmlspecialchars($form['maxHeight'])?>" />

<?php foreach ($form['files'] as $name => $file) : ?>

    <div class="uploader">
        <h2><?=htmlspecialchars($file['label'])?></h2>
        <p>
            <input type="text" class="filename" />
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" style="visibility:hidden;width:0;height:0" />
            <button type="button" name="doSelect" class="btn btn-sm">選択</button>
            <button type="button" name="doReset" class="btn btn-sm">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <p>
            <div class="thumbnail">
<?php if (isset($file['dataUri'])) : ?>
                <img src="<?=htmlspecialchars($file['dataUri'])?>" />
<?php endif ?>
            </div>
            <div class="fileinfo">
<?php if (isset($file['name'])) : ?>
                <h3><?=htmlspecialchars($file['name'])?></h3>
<?php endif ?>
                <ul>
<?php if (isset($file['size'])) : ?>
                    <li>ファイルサイズ:<?=htmlspecialchars($file['size'])?></li>
<?php endif ?>
<?php if (isset($file['type'])) : ?>
                    <li>ファイル種別:<?=htmlspecialchars($file['type'])?></li>
<?php endif ?>
<?php if (isset($file['width'])) : ?>
                    <li>横幅:<?=htmlspecialchars($file['width'])?></li>
<?php endif ?>
<?php if (isset($file['height'])) : ?>
                    <li>高さ:<?=htmlspecialchars($file['height'])?></li>
<?php endif ?>
<?php if (isset($file['moved']['path'])) : ?>
                    <li>配置先:<?=htmlspecialchars($file['moved']['path'])?></li>
<?php endif ?>
                </ul>
            </div>
        </p>
        <ul class="uploading-error">
<?php if (isset($file['errors'])) : ?>
<?php foreach ($file['errors'] as $error) : ?>
            <li class="error"><?=htmlspecialchars($error)?></li>
<?php endforeach ?>
<?php endif ?>
        </ul>
    </div>

<?php endforeach ?>

    <p class="form-footer">
        <input type="submit" value="送信する" class="btn btn-lg btn-ok" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript" src="uploader.js"></script>

</body>
</html>

今回はファイル選択欄にスタイルシートでデザインを適用したいという要望に応えるため、これを隠した上でJavaScriptからテキスト入力欄へのクリックやボタンのクリックを拾って、ファイル選択欄を間接的にクリックさせる方法を採りました。

この手法はMozilla Developer Networkの以下の記事を参考にしました。

一つの入力欄で複数のファイルを選択させることもできますが、私の現場ではそれだと使いづらいという意見が出ているため、今のところこの方法が良いと考えています。

(こういうのもクリックジャッキングの一種と言えますし、あまり推奨される手法ではないとは思うのですが、現状でファイル選択欄の見た目を変える方法がないので…)

また、いくつかスタイル定義を追加したり、ファイル毎にバリデーションエラーを表示する枠を設けています。DOM操作用にclass属性を付与しているところもあります。

こんな感じで複数ファイルを受け付けるフォームが表示されます。

せっかくなので、スタイルシートでちょっとだけCSS3のプロパティを使ったりして、ボタンをナウい感じにしてみました!(ボタンだけ)

マウスカーソルをボタンに乗せたとき

ボタンを押したとき

アップローダJavaScript

JavaScriptは長くなったので別ファイルに定義しました。

multi/uploader.js

$(function() {

    var MAX_FILESIZE = $('#MAX_FILESIZE').val();
    var MAX_WIDTH = $('#MAX_WIDTH').val();
    var MAX_HEIGHT = $('#MAX_HEIGHT').val();

    var clearFile = function(uploader) {
        $(uploader).find('input[type="file"]').val('');
        $(uploader).find('input.filename').val('');
        $(uploader).find('.thumbnail').html('');
        $(uploader).find('.fileinfo').html('');
    };

    var clearErrors = function(uploader) {
        $(uploader).find('.uploading-error').html('');
    };

    var appendError = function(uploader, message) {
        $(uploader).find('.uploading-error').append($('<li class="error">').text(message));
    };

    // バイト数を指定された小数点桁数の単位付き表示に変換
    var formatBytes = function(bytes, decimals) {
        var units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
        var number = '';
        var unit = '';
        var value = bytes;
        for (var i = 0; i < units.length; i++) {
            unit = units[i];
            number = value;
            if (value < 1024) {
                break;
            }
            value = value / 1024;
        }
        // @via http://www.jacklmoore.com/notes/rounding-in-javascript/
        return Number(Math.round(number + 'e' + decimals) + 'e-' + decimals).toString() + unit;
    };

    var preview = function(uploader, file) {
        if (file.size > parseInt(MAX_FILESIZE, 10)) {
            appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
            clearFile(uploader);
            return;
        }
        if (file.type.substring(0, 6) === 'image/') {
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html($('<img>').attr('src', canvas.toDataURL('image/png')));
                    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                    if (image.width > parseInt(MAX_WIDTH, 10)) {
                        appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                    }
                    if (image.height > parseInt(MAX_HEIGHT, 10)) {
                        appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                    }
                }
            });
            $(uploader).find('input.filename').val(file.name);
        }
    };

    $('.uploader input.filename').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doSelect"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doReset"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        clearFile(uploader);
    });

    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        preview(uploader, event.target.files[0]);
    });

});

ダミーフォームのためイベント定義が増えたり、クライアント側バリデーションエラーを扱うようになったことで、エラーメッセージの状態変更なども追加しています。

JavaScriptのコードがダサいのは勘弁してください。自分、MVVMとかよく分からないただのLAMPおじさんですので…。

クライアント側バリデーションエラーの時はこんな感じでエラーメッセージを表示します。

バリデーションエラーが解決されるまで送信ボタンを押せなくする等の工夫も入れた方が良いのでしょうけど、とりあえず表示のみに留めています。

クライアント側バリデーションエラーを無視して送信すると、このようにサーバ側バリデーションのエラーメッセージが表示されます。

クライアント側バリデーションエラーがなければこんな感じ。

サーバ側バリデーションエラーがなければ、前回と同様にそれぞれファイルの情報を表示します。

一時ファイル名をその都度生成してますので、同じファイルを送信してもOKです。

余談ですが、ファイルのレスポンスを返す際、アップロード時のファイル名を Content-Disposition ヘッダで返したいケースも当然あるでしょうし、他にも元画像の横幅や高さ、MIMEタイプなどもサーバ側で管理しておくと色々と便利です。(この辺、どういう風に扱うのが常道なんでしょうか?)

いずれにせよアプリケーションで管理する画像はデータベースから取得した他の情報に紐付けて扱うことになるので、最近はCMS系の小さい画像で良いアプリケーションでは、ファイルの内容をbase64エンコードしてデータベースに突っ込んだりしています。

たとえば「商品」エンティティと「画像」エンティティがあって、「商品画像」連関エンティティがあり、画像ファイル単体のレスポンスに必要な情報は「画像」エンティティに集約するような構造です。

SQLアンチパターンの「ファントムファイル」も解消できるし、SQLファイルでのダンプも可能、DataURIスキームでの利用も容易ということで、この方法を採用してからは表示側の処理もシンプルに作れるようになりました。(巨大なファイルを扱うケースではどうなのか分かりませんが…)

あえて「ファントムファイル」を採用する理由はアクセス負荷なのでしょうけど、単体のファイルレスポンスだったら普通にアプリケーション側でHTTPキャッシュに対応しますよね。それに、CMS系アプリケーションであれば、いずれにせよ紐付けられたエンティティの公開状態なども併せて見ないといけないわけで。

しかし、検索しても「ファントムファイル」を本気でアンチパターンと受け取ってない人が多いのが不思議です。ソーシャルなんとかやコンシューマ向け大規模サービスだとデメリットの方が大きいのかな。(今どきファイルはS3とか外部ストレージに置いてるってことなのかも…というかRDBMS自体使ってない?)

次の課題はドラッグ&ドロップ対応あたりでしょうか。もうちょっとだけ続けます。

画像アップロード(1)Canvasでプレビューして普通に画像をアップロード

AJAXとかドラッグ&ドロップといったややこしい処理はおいといて、HTML5Canvasを使ってクライアント側でプレビューしてみるテストです。

動作確認した環境は以下の通りです。

Volcanus_CanvasResizer.jsでCanvasプレビューする

いきなり俺々ライブラリ登場で恐縮ですが…。

Volcanus_CanvasResizer.js は今回のような処理のために書いた createObjectURL or FileReader + Canvas で画像をリサイズして表示するだけのものです。

/**
 * Volcanus_CanvasResizer
 * 
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
var Volcanus_CanvasResizer = {
    create: function(args) {
        var instance = {
            file: null
            ,maxWidth: 0
            ,maxHeight: 0
            ,objectUrl: null
            ,onLoad: null
            ,init: function(args) {
                if (typeof args.file !== 'undefined') {
                    this.file = args.file;
                }
                if (typeof args.maxWidth !== 'undefined') {
                    this.maxWidth = args.maxWidth;
                    if (typeof args.maxHeight !== 'undefined') {
                        this.maxHeight = args.maxWidth;
                    }
                }
                if (typeof args.maxHeight !== 'undefined') {
                    this.maxHeight = args.maxHeight;
                    if (typeof args.maxWidth !== 'undefined') {
                        this.maxWidth = args.maxHeight;
                    }
                }
                if (typeof args.onLoad !== 'undefined') {
                    this.onLoad = args.onLoad;
                }
                var url = window.webkitURL || window.URL;
                var createObjectURL = (url && url.createObjectURL);
                var image = new Image();
                var _self = this;
                image.onload = function() {
                    var dist = _self.getSize(image.width, image.height);
                    var canvas = document.createElement('canvas');
                    var context = canvas.getContext('2d');
                    canvas.width = dist.width;
                    canvas.height = dist.height;
                    context.drawImage(image, 0, 0, image.width, image.height, 0, 0, dist.width, dist.height);
                    if (typeof _self.onLoad !== 'undefined') {
                        _self.onLoad(image, canvas);
                    }
                    if (url && _self.objectUrl) {
                        url.revokeObjectURL(_self.objectUrl);
                    }
                };
                if (typeof createObjectURL !== 'undefined') {
                    image.src = this.objectUrl = url.createObjectURL(this.file);
                } else {
                    var reader = new FileReader();
                    reader.onload = function(event) {
                        image.src = event.target.result;
                    }
                    reader.readAsDataURL(this.file);
                }
                return this;
            }
            ,getSize: function(srcWidth, srcHeight) {
                var dist = {
                    width: srcWidth
                    ,height: srcHeight
                }
                if ((this.maxWidth > 0 && srcWidth > this.maxWidth) || (this.maxHeight > 0 && srcHeight > this.maxHeight)) {
                    var w_percent = (100 * this.maxWidth) / srcWidth;
                    var h_percent = (100 * this.maxHeight) / srcHeight;
                    if (w_percent < h_percent) {
                        dist.width = this.maxWidth;
                        dist.height = Math.floor((srcHeight * w_percent) / 100);
                    } else {
                        dist.width = Math.floor((srcWidth * h_percent) / 100);
                        dist.height = this.maxHeight;
                    }
                    if (dist.width < 1) {
                        dist.width = 1;
                    }
                    if (dist.height < 1) {
                        dist.height = 1;
                    }
                }
                return dist;
            }
        }
        instance.init(args);
        return instance;
    }
};

以下はこのライブラリを使ったHTML + JavaScriptだけの最小サンプルです。

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>CanvasResizer 最小サンプル</title>
</head>

<body>
<form method="post" enctype="multipart/form-data">
    <div class="uploader">
        <p>
            <input name="sample_image" type="file" accept="image/*" />
        </p>
        <div class="thumbnail"></div>
    </div>
    <p>
        <input type="submit" value="送信する" />
    </p>
</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript">/*<![CDATA[*/
$(function() {
    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var file = event.target.files[0]
        if (file.type.substring(0, 6) === 'image/') {
            var uploader = $(this).parent().parent()[0];
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html(
                        $('<img>').attr('src', canvas.toDataURL('image/png'))
                    ).append(
                        $('<h3>').text(file.name)
                    ).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + file.size))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                }
            });
        }
    });
});
/*]]>*/</script>

</body>
</html>

Volcanus_CanvasResizer.create({...}) で対象ファイル、横幅の最大値、高さの最大値、画像ファイルを読み終えた際のコールバックを指定しています。

コールバックの引数に元画像のImageオブジェクトとリサイズされたCanvasが渡されますので、 Canvas.toDataURL() でDataURLに変換してプレビュー表示したり、元の元の横幅、高さなどを取得できます。

フォーム上でファイルを選択すると、こんな感じで指定した横幅と高さの最大値に合わせてリサイズされたプレビュー画像が表示されます。

クライアントのメモリ量に左右されますので、巨大なファイルだとこういうことになります…。(私の拙いJavaScript力だとこんなもんです…)

Volcanus_FileUploaderでPHPのファイルアップロード処理を簡潔に書く

またまた俺々ライブラリ登場で恐縮ですが…。

Volcanus_FileUploader は今回のような処理のために書いた…(略)

Uploaderクラス + FileValidatorクラス + Fileクラス (FileInterface実装クラス) という構成になっていて、これらを組み合わせて使います。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;
use Volcanus\FileUploader\Exception\FilenameException;
use Volcanus\FileUploader\Exception\FilesizeException;
use Volcanus\FileUploader\Exception\ExtensionException;
use Volcanus\FileUploader\Exception\ImageWidthException;
use Volcanus\FileUploader\Exception\ImageHeightException;

$form = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => 'jpg,png',
        'maxWidth' => 400,
        'maxHeight' => 400,
        'maxFilesize' => '1M',
    ]);

    if (isset($_FILES['sample_image'])) {

        $uploadedFile = new NativeFile($_FILES['sample_image']);

        try {

            $uploader->validate($uploadedFile, $validator);

            $form['name'] = $uploadedFile->getClientFilename();
            $form['size'] = $uploadedFile->getSize();
            $form['type'] = $uploadedFile->getMimeType();
            $form['dataUri'] = $uploadedFile->getContentAsDataUri();

            if ($uploadedFile->isImage()) {
                if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                    $form['width'] = $width;
                    $form['height'] = $height;
                }
            }

            $form['movedPath'] = $uploader->move($uploadedFile);

        } catch (FilenameException $e) {
            $error = 'ファイル名が不正です。';
        } catch (FilesizeException $e) {
            $error = sprintf('ファイルサイズが%sバイトを超えています。', $validator->config('maxFilesize'));
        } catch (ExtensionException $e) {
            $error = sprintf('アップロード可能なファイルは%sです。', $validator->config('allowableType'));
        } catch (ImageWidthException $e) {
            $error = sprintf('横幅が%spxを超えています。', $validator->config('maxWidth'));
        } catch (ImageHeightException $e) {
            $error = sprintf('高さが%spxを超えています。', $validator->config('maxHeight'));
        } catch (\Exception $e) {
            $error = 'アップロードに失敗しました。';
        }
    }

}
include __DIR__ . '/template.php';

バリデーションはFileValidatorクラスで受け付けるファイルタイプやファイルサイズ、画像の横幅や高さを指定した上で、Uploader::validate() の第1引数にFile、第2引数にFileValidatorを渡して実行します。

バリデーションエラーは例外でスローされますので、適宜これをキャッチすることでエラーメッセージを出力できます。(複数の項目にバリデーションエラーがある場合は最初に検知したもののみとなります…ここはいずれ変えるかも…)

※2015-03-19追記:最新版ではバリデーションエラー発生時に例外をスローするかどうかをオプションで指定できるようにしました。

上記サンプルでは、バリデーション結果がOKの場合のみ、DataURI形式で画像を取得するとともに、ファイルを移動して移動先のパスを取得しています。この辺りは画像ファイルのアップロードではお決まりの処理になるんじゃないでしょうか。(ファイルが巨大だとDataURI生成でメモリがアウアウしてしまうかもしれませんが…)

前述の Volcanus_CanvasResizer.js に加えて、上記サーバ側で取得した変数の表示を組み込むとこんな感じです。(素のPHP

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>CanvasResizer + Volcanus_FileUploaderサンプル</title>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data">

    <div class="uploader">
        <p>
            <input name="sample_image" type="file" accept="image/*" />
        </p>
        <div class="thumbnail">
<?php if (isset($form['dataUri'])) : ?>
            <img src="<?=htmlspecialchars($form['dataUri'])?>" />
<?php else : ?>
            <img data-src="/js/holder.js/400x400" />
<?php endif ?>
<?php if (isset($form['name'])) : ?>
            <h3><?=htmlspecialchars($form['name'])?></h3>
<?php endif ?>
            <ul>
<?php if (isset($form['size'])) : ?>
                <li>ファイルサイズ:<?=htmlspecialchars($form['size'])?></li>
<?php endif ?>
<?php if (isset($form['type'])) : ?>
                <li>ファイル種別:<?=htmlspecialchars($form['type'])?></li>
<?php endif ?>
<?php if (isset($form['width'])) : ?>
                <li>横幅:<?=htmlspecialchars($form['width'])?></li>
<?php endif ?>
<?php if (isset($form['height'])) : ?>
                <li>高さ:<?=htmlspecialchars($form['height'])?></li>
<?php endif ?>
<?php if (isset($form['movedPath'])) : ?>
                <li>配置先:<?=htmlspecialchars($form['movedPath'])?></li>
<?php endif ?>
            </ul>
        </div>
    </div>

    <p>
        <input type="submit" value="送信する" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript">/*<![CDATA[*/
$(function() {
    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var file = event.target.files[0]
        if (file.type.substring(0, 6) === 'image/') {
            var uploader = $(this).parent().parent()[0];
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html(
                        $('<img>').attr('src', canvas.toDataURL('image/png'))
                    ).append(
                        $('<h3>').text(file.name)
                    ).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + file.size))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                }
            });
        }
    });
});
/*]]>*/</script>

</body>
</html>

バリデーションエラーの時はこんな感じでメッセージ表示しています。

バリデーションエラーがなければアップロードファイルの内容をDataURIで取得した後でファイルを移動し、DataURIの画像と移動先のパスを表示しています。

なお移動後のファイル名は sha1(uniqid(mt_rand(), true)) とアップロード時の拡張子で決定されます。

確認画面を入れたい、ファイル選択がださいのでスタイル適用したい、ドラッグ&ドロップ対応、かつIEでも動くように、など様々な要求に対応しようとすると大変ですが、ひとまずクライアント側プレビューとサーバ側バリデーションとアップロードのシンプルな流れは実装できました。

参考にした情報

PHPUnit 4.1系で \Symfony\Component\HttpFoundation\File\UploadedFile のモックオブジェクトを作成しようとすると "Erroneous data format for unserializing" のエラーが発生した件

PHPUnit 4.1系で \Symfony\Component\HttpFoundation\File\UploadedFile のモックオブジェクトを作成しようとすると "Erroneous data format for unserializing" のエラーが発生します。

PHP 5.6.1 + PHPUnit 4.1.6 で確認しました。

エラーメッセージで検索してみると、phpunit/phpunit-mock-objects にこんなissueが。

whatthejeff さん曰く

Unfortunately, since Symfony\Component\HttpFoundation\File\UploadedFile extends SplFileInfo, you will not be able to instantiate it without calling its constructor in 5.5.16. This was previously possible with a serialization hack, but it has been deemed unsafe by the PHP core team.

(英語分からないので機械翻訳を元にしたフィーリング訳ですが)

Symfony\Component\HttpFoundation\File\UploadedFile は SplFileInfo を継承していますが、組み込みオブジェクトの多くがシリアライズ不可なため、以前は "serialization hack" と呼ばれる手段で回避していたとのこと。

しかし、PHP 5.5.16 から(?)この方法が安全ではないと判断されて禁止され、コンストラクタを呼ばずにインスタンスを生成できなくなったため、エラーが発生するようになったみたいです。

issueのコメントからリンクされていたコミットを見たところ phpunit/phpunit-mock-objects の 2.3で対処されたみたい?

差分を見たところ ReflectionClass::isInternal() での分岐処理が追加されてます。

ユーザークラスの場合は ReflectionClass::newInstanceWithoutConstructor() で生成されて、内部クラスの場合は従来通りのコード、つまり "serialization hack" で生成しているようです。

クラス名を元に空のオブジェクトをシリアライズした文字列を組み立てて unserialize() すると、インスタンスが生成できるというトリックのようですね。

<?php
// We have to use this dirty trick instead of ReflectionClass::newInstanceWithoutConstructor()
// because of https://github.com/sebastianbergmann/phpunit-mock-objects/issues/154
$object = unserialize(
    sprintf('O:%d:"%s":0:{}', strlen($className), $className)
);

コメントを見たところ、そもそもこのトリックを使い出したきっかけもSymfony2のテストで不具合が発生したためのようです。

ともあれ、現在の phpunit/phpunit-mock-objects のバージョンを調べてみると…。

$ composer global show -i phpunit/phpunit-mock-objects
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
name     : phpunit/phpunit-mock-objects
descrip. : Mock Object library for PHPUnit
keywords : mock, xunit
versions : * 2.1.5
type     : library
license  : BSD-3-Clause
source   : [git] https://github.com/sebastianbergmann/phpunit-mock-objects.git 7878b9c41edb3afab92b85edf5f0981014a2713a
dist     : [zip] https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/7878b9c41edb3afab92b85edf5f0981014a2713a 7878b9c41edb3afab92b85edf5f0981014a2713a
names    : phpunit/phpunit-mock-objects

(以下略)

2.1.5らしい。こりゃあかんわ。

とりあえずPHPUnitのバージョンを4.1系から4.2系に上げてみます。

$ composer global require "phpunit/phpunit=4.2.*"
$ composer global update
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing doctrine/instantiator (1.0.4)
    Downloading: 100%

  - Removing phpunit/phpunit-mock-objects (2.1.5)
  - Installing phpunit/phpunit-mock-objects (2.3.0)
    Downloading: 100%

  - Removing phpunit/phpunit (4.1.6)
  - Installing phpunit/phpunit (4.2.6)
    Downloading: 100%

Writing lock file
Generating autoload files

試してみたところ、これで通るようになりました。同じ問題に引っ掛かった方へのヒントになれば幸いです。