В погоне за оптимизацией сайтов захотел уменьшить количество запросов, не в ущерб размерам оптимизированных файлов.
Цель — передавать в одном файле изображения разных форматов, с разными настройками оптимизации.
Как средство, выбрал data uri и gzip'нутый css файл. Однако IE с data uri работают из рук вон плохо. Но в них есть mhtml. Существовавшая реализация не отвечала моим требованиям, т.к. приходилось 1 файл передавать два раза — раз для IE, в mhtml, и второй для всех остальных, в data uri. В поисках решения наткнулся на статью bolk'а, где описывалось решение для формата jpeg и некоторые теоретические выкладки для gif и png. После почти трехнедельного р��скуривания манов мне удалось реализовать решение для gif и png и автоматизировать процесс для всех трех форматов.

BASE64


Поскольку изображения передаются в base64, стоит осветить некоторые, связанные с этой кодировкой, моменты
  • Во первых: base64 понимает только [A-Za-z0-9+/].
    При дешифровке браузерами или ruby либой все прочее символы дропаются.
    Консольный base64 на линуксе их не дропает и выводит ошибку.
  • Во вторых: base64 конвертит каждые 3 исходных байта (в нашем случае, ASCII символы) в 4 ASCII символа.
    Поэтому декодированную CSS строку надо уравновешивать, к примеру, нулями, что бы она правильно себя вела внутри закодированного файла.


Информация к размышлению:
Base64 в англоязычной викепедии



JPEG


Формат секций в JPEG:[заголовок][data]

C jpeg все просто и описано у bolk'а:
Открываем HEX-редактор:
FF D8 — заголовок JPEG для IE
FF E0 — объявление секции APP0, куда прячется всё до данных изображения,
«;background-color:url(data:image/jpeg;base64,» — это видят остальные браузеры.
      Когда IE декодирует эту строку, то получается хлам, который ни на что не влияет
FF D8 — начало JPEG для остальных браузеров
«данные изображения» — это место видят уже все браузеры


Смысл в том, что бы строка в CSS выглядела как:



И дешифровалась в IE как:



А другие ее видели как:



Из-за особенностей base64 надо дополнительно передавать некоторое кол-во символов, что бы строка шифровалась\дешифровалась верно. Они вставляются до и после CSS. Количество просчитывалось и подбиралось опытным путем:
/9j/4AA0;background-image:url(data:image/jpeg;base64;00,

Мой скрипт, который это процесс автоматизирует:
#!/usr/bin/ruby
require 'base64'
# тут строка ВСЕГДА равна одному значению:
a="/9j/4AA0;background-image:url(data:image/jpeg;base64;00,"

#Основной файл
b=Base64.encode64(File.open("#{ARGV[0]}",'r'){|f| f.read})

# Запись в файл
File.open('temp','w'){|i| i.write("#{Base64.decode64(a)}#{Base64.decode64(b)}")}

# перегонка файла обратно в base64
#cat test | base64 | tr -d "\n" > jpeg64.txt
File.open('temp2','w'){|o| o.write(Base64.encode64(File.open('temp','r'){|f| f.read}))}
#File.delete('temp')

c=File.open('temp2','r'){|f| f.read}.gsub(/\/9j\/4AA0backgroundimageurldataimage\/jpegbase6400/,"/9j/4AA0;background-image:url(data:image/jpeg;base64;00,").gsub(/\n/,"")

File.open('out_jpeg64','w'){|s| s.write("#{c}\);")}
File.delete('temp2')
# можно вставлять в css
# cat output64 | tr -d "\n"
# и хорошо поверить mhtml!!!


* This source code was highlighted with Source Code Highlighter.


Информация к размышлению:
JPEG в англоязычной викепедии



GIF


С этим форматом дела обстоят не так хорошо.
  • Во первых, размер его секции задает 1 байт, т.е. максимальная длина секции ff или 255 символов.
  • Во вторых: размер секции Comments почему-то ограничен размером в 240 байт, и 30 символов занимает CSS строка и еще несколько надо для 'уравновешивания' base64.
  • В третьих: Есть всего 2 блока, куда можно запихнуть 'мусор' — Application Extension и Comment Extension и они не могут идти перед General Color table. А таблица цветов может занимать максимум 256*3=768 байт.

Что можно сделать:
  • Не трогать General Color Table, если кол-во цветов не превышает ~70
  • Переместить содержимое General Color Table в Local Color Table


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

Более менее стандартный вариант секций GIF: [заголовок][размер][data][00]

Многие GIF имеют не совсем корректный порядок полей. Например если сделать `convert jpeg gif` то полученный файл адекватно обрабатываться скриптом не будет. Юзайте GIMP.
Первые 13 байт это та инфа, сокращать которую нельзя. Причем 11 байт является сложно-составным и описывает Global Color Table. Его меняем на 00
Вырезаем таблицу цветов (от 14 байта и до камента — 21 FE xx, где xx — размер комм��нта)
Коммент с css и первыми 13ю байтами.
Вырезаем таблицу цветов (от 14 байта и до камента — 21 FE xx, где xx — размер коммента)
'Внутренний кoммент' длиной в 1 символ
Вырезаем таблицу цветов (от 14 байта и до камента — 21 FE xx, где xx — размер коммента)
2c 00 00 00 00 — Image descriptor. Его 10й байт является сложно-составным и описывает Local Color Table. Переносим из 11-го байта все что переносится (объявить Local Color Table, сортирована\нет, размер Local color table), подробнее в спецификации формата.
Вставляем таблицу цветов
Продолжение Image descriptor


Смысл в том, что бы строка в CSS выглядела как:



При том что до всех правок файл выглядел как:



Мой скрипт для автоматизации процесса:
#!/usr/bin/ruby
# CONVERT INCORRECTLY TRANSFER DATA. USE GIMP INSTEAD
# USE: ./GIF_SCRIPT.RB [GIF_FILE]
require 'base64'

# OPEN GIF FILE IN HEX
orig=File.open("#{ARGV[0]}",'r'){|f| f.read.unpack("H*")}.to_s

# FUTURE HEADER
header=orig[0..25]

# GREP GENERAL COLOR TABLE
# [26..1565]/6 = 256 BYTE (MAX SIZE OF COLOR TABLE)
color_table=orig[26..1565][/(.*)21fe/,1]
if color_table.class == NilClass
  color_table=orig[26..1575][/(.*?)2c0000/,1]
end

# FOR DEBUGING
#puts color_table
#puts color_table.length
puts "COLORS IN PALLETE: #{color_table.length/6}"

# GIF IMAGE DATA
data=orig[/2c0000.*/]

# SAVE 11 BYTE'S INFO AND ADOPT IT FOR LOCAL COLOR TABLE
eleven=header[20..21].to_i(16).to_s(2)
local_mix="10#{eleven.split("")[4].to_s}00#{eleven.split("")[5..7].to_s}".to_i(2).to_s(16)

# 11 BYTE TO ZERO
header[20..21]="00"
# DECLARE LOCAL COLOR TABLE
data[18..19]=local_mix

# MAGIC COMMENT
comment=Base64.decode64(";background-image:url(data:image/gif;base64;pzd,").unpack("H*").to_s

# WRITE ALL IN ONE FILE
var=header+"21fe313030"+comment+header+"21fe013000"+data[0..19]+color_table+data[20..-1]
File.open('
out.gif','w'){|f| f.write(var.to_a.pack("H*"))}

# ENCODE FILE TO BASE64 WITH "\n" REMOVING
File.open('
temp','w'){|o| o.write(Base64.encode64(File.open('out.gif','r'){|f| f.read}).gsub(/\n/,""))}

# MAKE STRING CSS READEABLE
c=File.open('
temp','r'){|f| f.read}.gsub(/backgroundimageurldataimage\/gifbase64pzd/,";background-image:url(data:image/gif;base64;pzd,").gsub(/\n/,"")
File.delete('
temp')

# JUST PASTE TEXT FROM THIS FILE TO CSS
File.open('
out_gif64','w'){|s| s.write("#{c}\);")}


* This source code was highlighted with Source Code Highlighter.



Для анимированного гифа скрипта нет. Я считаю что лучше использовать анимированными CSS sprites.

Теоретические выкладки:
  • Для каждого кадра делать Local Color Table смысла не имеет, т.к. это увеличит размер.
  • Анимированные гифы с кол-вом цветов 64 могут обрабатываться с включением General Color Table в коммент
  • Application Extension и Comment Extension могут идти подряд, что увеличивает возможный размер x2.
  • На просторах интернета я встретил информацию о том, что в Application Extension фактически 2 блока задают размер.
    21 ff SizeSize 'NETSCAPE2.0' SizeSize 01 00 00, где SizeSize — 2 байта отвечающие за размер, а 01 байт отвечающий за infinitive loop.
  • Что, в теории, может предоставить возможность 'забить' большее кол-во цветов. Но все-равно меньше 256 (около 230).



Информация к размышлению:
GIF color tables
Gif specification



PNG


После gif это тихая гавань. У секций размер не ограничен, у них 4байтные заголовки и их очень удобно искать. Для сравнения, для gif я ломал голову и дебажил скрипт почти весь день, а для png все сделал за час.

Формат секций в PNG: [размер(4 байта)][data][CRC(4 байта)]

И тут не обошлось без подводных камней. CRC очень важен для IE, если CRC битый то IE не будет отображать картинку. Всем же остальны�� глубоко параллельно битый он или нет.

Многие PNG имеют не совсем верную структуру, во всяком случае, мой скрипт с нми работать не будет, пока не прогнать их через optipng. Помимо оптимизации изображения, эта прога выставит поля в нужном порядке. Также, мною замечено что Photoshop иногда режет поля sRGB и им сохраненные png обрабатываются не всегда.

CSS будем прятать в секции tEXt

PNG надо сразу заоптимизировать с помощью optipng, потом нарезать таким образом, что бы tExt был сразу за IHDR.
В секции tEXt обязательно должен передаваться keyword00, его длина учитывается в общей длине секции. У меня это 'Comment '

Общий порядок:
IHDR
tExt
Другая служебная информация
data



Было:



Стало:



Скрипт хорошо комментирован, и в спецификации тоже можно многое почерпнуть

IE6 не видит прозрачности, иногда это можно исправить с помощью bKGD выставляя нужный Background color.

После чего запускаем `optipng -fix FILE` что бы исправить CRC секции tEXt

Мой скрипт для автоматизации процесса:
#!/usr/bin/ruby
#
#!!!! RUN optipng FIRST !!!!
#
# USE: ./PNG_SCRIPT.RB [PNG_FILE]
require 'base64'
# OPEN GIF FILE IN HEX
orig=File.open("#{ARGV[0]}",'r'){|f| f.read.unpack("H*")}.to_s

#ihdr=orig[0..65]
ihdr=orig[/(.*?)73524742/,1][0..-9]

#sRGB - 73 52 47 42 & -4b (8 characters)
#srgb_phys=orig[66..171]
#check for tEXt existence
if orig[/74455874/].class == NilClass
  srgb_phys=orig[/(.{8}73524742.*?)49444154/,1][0..-9]
else
  srgb_phys=orig[/(.{8}73524742.*?)74455874/,1][0..-9]
end

#srgb_phys=orig[/(.{8}73524742.*?)74455874/,1][0..-9]

#tEXt - 74 45 58 74 –њ–Њ—Б–ї–µ–і–љ–Є–µ 8 –љ–∞–і–Њ –Љ–µ–љ—П—В—М –љ–∞ CRC 00000000
#text=orig[172..245]
#text=orig[/(.{8}74455874.*?)49444154/,1][0..-9]

#IDAT - 49444154
#data=orig[246..-1]
data=orig[/.{8}49444154.*/]

#MAGIC COMMENT
comment=Base64.decode64(";background-image:url(data:image/png;base64;pzd,").unpack("H*").to_s

###### OUTER PNG
# "00000059"+"74455874"+"436f6d6d656e7400"
# tEXt_length + 'tEXt' + 'Comment.'
# "3030" - two zero for base64 balance
###### INNER PNG
# "00000008"+"74455874"+"436f6d6d656e7400"+"00000000"
# min_tEXt_length + 'tEXt' + 'Comment.' + blank CRC
#
# CRC field one for two PNG's
# IE can'
t live without it, but others feel indifferently
var=ihdr+"00000059"+"74455874"+"436f6d6d656e7400"+"3030"+comment+ihdr+"00000008"+"74455874"+"436f6d6d656e7400"+"00000000"+srgb_phys+data

File.open('out.png','w'){|f| f.write(var.to_a.pack("H*"))}

# CRC FIX
puts "optipng -fix started..."
`optipng -fix out.png`
puts "optipng -fix completed"

# ENCODE FILE TO BASE64 WITH "\n" REMOVING
File.open('temp','w'){|o| o.write(Base64.encode64(File.open('out.png','r'){|f| f.read}).gsub(/\n/,""))}

# MAKE STRING CSS READEABLE
c=File.open('temp','r'){|f| f.read}.gsub(/backgroundimageurldataimage\/pngbase64pzd/,";background-image:url(data:image/png;base64;pzd,").gsub(/\n/,"")
File.delete('temp')

# JUST PASTE TEXT FROM THIS FILE TO CSS
File.open('out_png64','w'){|s| s.write("#{c}\);")}


* This source code was highlighted with Source Code Highlighter.


Информация к размышлению:
PNG Basics
PNG Specification



MHTML


Если используется MHTML то CSS должен быть весь отредактирован под него и разбит на секции(пример в архиве):
/*
Content-Type: multipart/related; boundary="_"

--_
Content-Type: text/css;

*/
html, body {
 margin: 0;
 padding: 0;
 width: 100%;
 height: 100%;
}

#half_logo {
/*
--_
Content-Location:logo
Content-Transfer-Encoding:base64
Content-Type: image/png;*/

iVBORw0KGgoAAAANSUhEUgAAAT4AAAA3CAMAAACintZ+AAAAWXRFWHRDb21tZW50ADAw;background-image:url(data:image/png;base64;pzd,iVBORw0K...);

/*
--_
Content-Type: text/css;

*/
background-image: url(mhtml:http://192.168.1.2/test.css!logo) !ie;
/*
--_--
*/

* This source code was highlighted with Source Code Highlighter.


Архив с исходниками и скриптами
Пример рабочего сайта

Тестировалось в FF 3.6, Opera 10.10, chromium, chrome, IE6-8

P.S: Автором этой статьи является мой хорший друг Banderlog. Статью размещаю по его просьбе, соответственно вопросы рекомендую задавать ему напрямую в jabber: banderlog@jabber.com.ua
P.P.S: Странно что только на второй день обнаружился тот факт что при посте статьи была допущена ацкая ошибка в скриптах. Все 3 были одинаковыми.