avatar

Alvaro

карма
20,0
40 голосов
рейтинг
2,0
25 ноября 2011 в 01:58

Загрузчик изображений и миниатюр. Новые стандарты, старые проблемы

HTML*

Предисловие


Всем привет. Не так давно я написал статью о создании загрузчика изображений на флеше. Там я упомянул, что загрузчик можно реализовать и с помощью html5 File API. Несколько вечеров и — ура — я это сделал. Настало время рассказать, какие приемы я использовал, в каких браузерах это работает, и стоит ли этим вообще пользоваться.
Напомню вкратце требования: необходимо реализовать загрузчик изображений, поддерживающий пакетную загрузку, создание миниатюр(и загрузку их на сервер), и приемлемый интерфейс.

Я прекрасно понимаю, что моя статья использует текущую реализацию ещё не до конца проработанного стандарта, а потому перечислю браузеры, актуальные на сегодняшний момент:
  1. Firefox 8
  2. Chrome 15
  3. Opera 11.60 beta
  4. Safari 5.1.1
  5. Internet Explorer 9

Теперь о грустном. Для ИЕ 9 нет реализации File API, поэтому его(браузер) я не буду рассматривать. Ну что ж поехали.

Внешний вид


С незапамятных времен стояла задача сделать стильной кнопку для вызова диалога выбора файлов. Поэтому в ход шли яростные костыли. Например, популярное решение — сделать инпут прозрачным и повесить поверх красивого дива. То есть всё зависит от инпута, от его размера. Все вышеперечисленные браузеры поддерживают иное решение. В них можно программно генерировать click инпута. А по сути вызывать диалог выбора файлов. А сам инпут можно легко скрыть:
<input id="input_file" type="file" multiple style="position:absolute; top:-999px; visibility:hidden"/>
<div id="button" style="background-color: blue; width: 100px; height:40px;"></div>

<script type="text/javascript">
var input = document.querySelector("#input_file");
var btn = document.querySelector("#button");
btn.onclick = function () {
    input.click();
};
</script>

Такой способ позволяет забыть об инпуте как о элементе управления, что по-моему очень удобно.

О загрузке файла в браузер


Для того, чтобы манипулировать данными файла, например, ресайзить картинку, необходимо получить эти данные. Для этого понадобится FileReader. Для того, чтобы создать миниатюры, возьмем Canvas и загрузим туда данные файла. Это возможно, если представить данные в виде base64:

var files;
var reader = new FileReader();
var cv = document.createElement("canvas");
var cvContext = cv.getContext("2d");

input.onchange = function () {
    files = input.files;
    reader.readAsDataURL(files[0]);
};

reader.onload = function (e) {

    var im = new Image();
    
    im.onload = function (e) {

        cv.width = 100;
        cv.height = 100;
        cvContext.drawImage(im, 0, 0, 100, 100);

        // здесь мы должны достать миниатюру из canvas и передать её на сервер вместе с оригиналом
        
    }
    im.src = reader.result;
};

Пока всё достаточно прозрачно. Однако надо сразу сказать, что загрузка данных в браузер не поддерживается в Сафари. Самое интересное, что загрузить файл на сервер можно, а в браузер нет. Не поддерживается ни FileReader, ни URL. Впрочем, для нашей задачи есть одно решение, но я бы его, если честно, использовать не стал. Позже я к этому вернусь.

О получении миниатюр и отправке на сервер


Итак. У нас есть оригинал изображения. У нас есть миниатюры в canvas. Нам нужно всё это достать, сгруппировать и отправить на сервер. Чего проще, правда? Вот тут и возникают проблемы. На этом этапе браузеры ведут себя совершенно по-разному. Рассмотрим решения для каждого. Разумеется, от простого к сложному.

Firefox

Тут всё просто. У canvas есть метод mozGetAsFile, название которого говорит само за себя. Фаерфокс также поддерживает FormData. А это значит, что есть контейнер для наших файлов. XMLHttpRequest легко отправит эти данные на сервер, где их можно подцепить. Процесс загрузки можно отслеживать с помощью upload.onprogress.

var blobData = cv.mozGetAsFile(name, files[0].type);

var form = new FormData();
 
form.append("Filedata0", files[0]);
form.append("Filedata1", blobData);

var xhr = new XMLHttpRequest();

xhr.open("POST", "load.php", true);

xhr.onload = function () {
    console.log(this.response);
}

xhr.upload.onprogress = function (e) {
    console.log(e.position / e.totalSize) * 100;
}

xhr.send(form);


Минус тут только один. Метод mozGetAsFile не дает возможности выбрать качество выгружаемого изображения.

Chrome

Вот тут никакого mozGetAsFile нет и в помине. Есть возможность получить изображение в base64(Это делает метод toDataURL). Но это меня не устроило, и я всё-таки привел изображение к blob. Комментарии в коде:
var BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;
 //получаем данные в виде base64, второй параметр задает качество (от 0 до 1)
var sBase64 = canva.toDataURL(type, 1);
var aBase64 = sBase64.split(',');

//раскодируем обратно
var sData = atob(aBase64[1]);

var aBufferView = new Uint8Array(sData.length);

//создаем ArrayBuffer на основе строки
for (var i = 0; i < aBufferView.length; i++) {
    aBufferView[i] = sData.charCodeAt(i);
}

// с помощью BlobBuilder переводим в blob
var builder = new BlobBuilder();

builder.append(aBufferView.buffer);

var blobData = builder.getBlob(type);


Вот эти данные уже можно записать в FormData и отправлять так же, как в фаерфоксе.

Opera

Вот тут у нас возникнут большие проблемы. Получить миниатюру и превратить её в ArrayBuffer можно так же, как и в Хроме, а вот как отправить? Opera не поддерживает FormData и BlobBuilder. А XMLHttpRequest может отправлять кроме текста только ArrayBuffer. Тут нам поможет опыт создания загрузчика на флеше. Нам придется самим генерировать заголовок формы с данными, записывать его в ArrayBuffer и отправлять.
var sBase64 = canva.toDataURL(type, 1);
var aBase64 = sBase64.split(',');

var sData = atob(aBase64[1]);

var aBufferView = new Uint8Array(sData.length);

for (var i = 0; i < aBufferView.length; i++) {
    aBufferView[i] = sData.charCodeAt(i);
}

var fBuilder = new FormBuilder();

fBuilder.addFile(aBufferView);

var form = fBuilder.getForm();

var xhr = new XMLHttpRequest();

xhr.open("POST", "load.php", true);

xhr.onload = function () {
    alert(this.response);
}

xhr.setRequestHeader('Content-type', 'multipart/form-data; boundary=' + fBuilder.BOUND);

xhr.send(form);

function FormBuilder() {

    this.getBoundary = function () {
        var _boundary = "";

        for (var i = 0; i < 0x20; i++) {
            _boundary += String.fromCharCode(97 + Math.random() * 25);
        }

        return _boundary;
    }


    this.addFile = function (name, buffer) {
         var sHeader = this.ADDB + this.BOUND;
         sHeader += this.ENTER;
         sHeader += 'Content-Disposition: form-data; name="Filedata' + this.index + '"; filename="' + name + '"';
         sHeader += this.ENTER;
         sHeader += 'Content-Type: application/octet-stream';
         sHeader += this.ENTER;
         sHeader += this.ENTER;

         this.index++;

         this.header = this.sumBuffers(this.header, this.StrToBuffer(sHeader), buffer, this.EnterBuffer);
    }

     this.addParam = function (name, value) {
         var sHeader = this.ADDB + this.BOUND;
         sHeader += this.ENTER;
         sHeader += 'Content-Disposition: form-data; name="'+ name + '"';
         sHeader += this.ENTER;
         sHeader += this.ENTER;
         sHeader += value;
         sHeader += this.ENTER;

         this.header = this.sumBuffers(this.header, this.StrToBuffer(sHeader));
     }


     this.getForm = function () {
         var sHeader = this.ENTER;
         sHeader += this.ENTER;
         sHeader += (this.ADDB + this.BOUND + this.ADDB);

         var aHeader = this.StrToBuffer(sHeader);

         return this.sumBuffers(this.header, aHeader).buffer;
     }

  

     this.StrToBuffer = function (str) {
         var buffer = new Uint8Array(str.length);
    
         for (var i = 0; i < buffer.length; i++) {
             buffer[i] = str.charCodeAt(i);
         }

         return buffer;
     }

     this.sumBuffers = function () {      
         var sumLength = 0, position = 0, aSumHeader;

         for (var i = 0; i < arguments.length; i++) {
                sumLength += arguments[i].length;
         }
         aSumHeader = new Uint8Array(sumLength);

         for (var i = 0; i < arguments.length; i++) {
             aSumHeader.set(arguments[i], position);
             position += arguments[i].length;
         }

         return aSumHeader;
     }

     this.BOUND = this.getBoundary();
     this.ENTER = "\r\n";
     this.EnterBuffer = this.StrToBuffer(this.ENTER);
     this.ADDB = "--";
     this.index = 0;

     this.header = new Uint8Array(0);
}



Это такой вольный перевод из actionscript в javascript моего класса из первой статьи. С его помощью мы по сути эмулируем FormData. Кстати, в Хроме он прекрасно работает. А вот фаерфокс ругается — он не умеет передавать ArrayBuffer.
Вернемся к Опере. Всё работает, однако отслеживать загрузку не получится: onprogress в Опере не поддерживается(как кстати и во флешевском URLLoader).

Safari

Я уже говорил выше, что в Сафари у нас нет доступа к данным файла, и поэтому сделать практически ничего нельзя. Однако если уж вы решили непременно сделать функциональный загрузчик изображений на html5 и с поддержкой Сафари, то псевдорешение есть. Дело в том, хоть и доступа к данным файла нет, но загрузить его на сервер можно. А уж на сервере можно делать всё, что угодно. Идея проста: получив и сохранив файл, передать его обратно(в виде base64 или просто ссылки с последующей загрузкой в Canvas). А уж тут попытаться реализовать один из предложенных выше вариантов. Естественно, способ нехорош, однако если совсем необходимо, то можно сделать и так.

Заключение


Выводы из всего вышеизложенного выходят довольно простые. Во-первых, File API ещё явно не созрел. Браузеры пытаются как-то поддерживать то, что есть в спецификации, но стандарт ещё на стадии обсуждения и доработки. Несмотря на это, всё-таки мы имеем довольно мощный инструмент, который позволяет решать задачи не только на бумаге.
Надеюсь, статья кому-нибудь поможет.

Пример


Привожу небольшое демо, функциональность там минимальна, однако демонстрирует, как это должно работать.
Да, и ещё. Минимальный код на сервере для этого примера вот такой:

foreach($_FILES as $key => $value){
	$filename = substr_replace($key, '.', -4, 1);
	move_uploaded_file($value['tmp_name'], $filename);		
}

echo 'complete'; 


Если будет нужен полноценный загрузчик со всеми вилюшками и фичами, то пишите. Может быть и сделаю.
И, конечно, не забываем, что версия Оперы для примера 11.60 beta.
+33
4980
167
Alvaro 2,0

Комментарии (39)

+23
fedclud #
Даа… очень удобная штука я гляжу этот HTML5
прям красота, под каждый браузер своя реализация. Блеск!
Всегда именно такую технологию и хотел освоить.
+11
PoN #
«Это сарказм !?» :)
+9
Wendor #
Браво Шелдон!
0
ShpuntiK #
Отличная технология, но как и любая не без погрешностей.
НЛО прилетело и опубликовало эту надпись здесь
0
fedclud #
Даааа. Это фигня…
Вот когда будет разный код под разные процы вот тогда мы похохочем!
А это так мелочи!
0
RReverser #
Вообще-то, был уже (и, собственно говоря, все еще есть). То, что от вас скрывают ассемблерный код — не значит, что его нету :)
+1
fedclud #
вот это пофиг, главно его самому не писать :)))
0
mrjj #
Ага, учитывая процент аудитории IE+Safari, то это все полезно только теоретически. Еще обязательно нужно добавить превью файлов с помощью canvasGL, чтобы к статье можно было смело дописать тег «экзотика»

Полагаю, что ближайшие 5 лет я продолжу пользовать в проектах qq.FileUploader и не париться, а подобные публикации пропускать.

Боюсь что, подавляющее большинство возможностей т.н. html5 придется еще долгое время игнорировать, а когда они либо сдохнут либо получат полноценные спецификации и поддержку со стороны браузеров, ну да, потрачу аж 15 минут на изучение.
0
Alvaro #
Вы считаете, что в наших родных краях процент IE+Safari так велик?
0
mrjj #
Края не имеют значения, важна аудитория, если она у вас блика к меилсрушечке, то ваше решение будет поддерживаться только у 1/5 пользователей.
По данным tx3, хотя они и несколько смещенные, но рунет отображают неплохо, чуть лучше, заработает где-то 1/3 пользователей.

Пользователь не станет ставить другой браузер, чтобы посетить ваш сайт. И возвращаться если что-то не заработало тоже вряд ли будет.

Впрочем, я не знаю зачем я тут все это пишу. Ваш проект — ваши проблемы.
0
Alvaro #
Конечно. Однако я тут нигде не писал, что нужно использовать только это решение(а даже намекал на обратное). Например, в связке с флешем всё будет отлично работать.
+1
fedclud #
К сожалению, согласен с тобой полностью. Такой html5 мне лично не нужен, будем ждать.
+1
Smerig #
Значит в сафари нельзя ниче сделать, как и в ИЕ, но сафари рассматриваем :)
А вообще способ для сафари подходит для любого браузера, даже для ИЕ.
Писал статью как-то www.clearboth.ru/article/ajax-image-preview-before-upload.html
Код сыроват, но работает.
+1
Alvaro #
Способ для Сафари к IE не подойдет, так как мы не можем получить даже список файлов, не то что передать их на сервер
0
Smerig #
Может неправильно понял, но ведь имена файлов в ИЕ нам известны: c:/fakepath/filename.jpg
+1
Alvaro #
Вы имеете ввиду локальный путь?
+3
evindor #
По рабочим задачам делали подобное. Для неподдерживаемых браузеров сделали fallback во флеш. На гитхабе сейчас сырая версия, в проекте лежит доработанная, как руки дойдут — обязательно выложим. Честно честно.
0
xrays72 #
На хабре была статья habrahabr.ru/blogs/webdev/109079/. Читали её?
0
Alvaro #
Да, читал. Однако как работать с миниатюрами, там не описано. А это оказалось самым сложным.
+1
xrays72 #
А как же это: "… Метод readAsDataURL() объекта FileReader принимает параметром объект File и запускает чтение данных из него. В результате для всех выбранных через стандартное поле или перетащенных прямо в браузер картинок, мы видим их миниатюры (искусственно уменьшенные до 150 пикселей)..."?

// Создаем объект FileReader и по завершении чтения файла, отображаем миниатюру и обновляем
// инфу обо всех файлах
var reader = new FileReader();
reader.onload = (function(aImg) {
return function(e) {
aImg.attr('src', e.target.result);
aImg.attr('width', 150);
log('Картинка добавлена: `'+file.name + '` (' +Math.round(file.size / 1024) + ' Кб)');
imgCount++;
imgSize += file.size;
updateInfo();
};
})(img);

reader.readAsDataURL(file);
0
Alvaro #
Видеть-то видим, но переслать на сервер не можем
0
Demetros #
e.target.result содержит закодированное в base64 содержимое файла
+1
Alvaro #
Да, конечно, я так и делал. Но отсылать на сервер файл в виде строки, да ещё закодированной? В принципе, в моей статье описывается решение одной проблемы: как данные миниатюры отправить так, чтобы на сервере можно было их легко получить и сохранить(например, используя массив $_FILES).
+2
Demetros #
Прошу прощения, неправильно понял.
Статья хорошая, чужой опыт всегда интересен и может пригодиться в будущем.
0
xfather #
Неплохо бы приложить рабочий пример
0
Alvaro #
Я над этим работаю. Скоро будет
0
fedclud #
да, да пример. хотелось бы, чтоб он рабол как можно в большем кол-ве браузеров.
0
Alvaro #
Будет пример. Сегодня не смогу — работа, пятница, отдых. А завтра, надеюсь, выложу и здесь кину ссылку
0
Denai #
В примере просто зелёная кнопка загрузки, которая ничего не делает кроме выбора файлов, так и должно быть? FF 8.0
0
Alvaro #
Выбираются файлы, ресайзятся и отправляются на сервер. Если сервер есть. Если сервера нет, то ничего не происходит. Код для сервера я привел здесь
0
Alvaro #
Напомню, что основная цель моего загрузчика — загрузить на сервер файл и его миниатюру.
0
mrjj #
Теоретически интересно. На практике, если у вас внезапно потребуется другой тип миниатюр или их размер изменится на один пиксель придется все таки делать пережималку на сервере, а если она есть, то зачем все эти нестабильные пляски на клиенте?
0
Alvaro #
Чтобы в режиме онлайн(когда пользователь грузит картинку) мой сервер не работал как трактор, пытаясь пережать изображение
0
mrjj #
Сколько же их вам загружают в секунду?!
Обычно это не есть узкое место.
0
Alvaro #
А если изображение, к примеру, хорошего качества(например 2900x1500). Это бывает в веб-галерее. Тогда оперативной памяти может и не хватить
0
mrjj #
Да чтож за железо-то такое. Если реально поджмает пользуй EPEG, хотя качество сжатия у него посредственное.

В любом случае это как экономия на ручках и пакетиках чая.
0
Alvaro #
Не согласен. Я столкнулся с этой проблемой в реальной задаче. А так как переплачивать за хостинг мне не хотелось, то я решил перенести затраты оперативы на клиент. Правда, в той задаче я использовал flash, но подход тот же.
0
JohnnyD #
Это статья о прелестях, прелестях подземных будней.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.