Сжатие css. Небольшая самописная утилитка

December 25, 2007Comments Off on Сжатие css. Небольшая самописная утилитка

Сказка про то как я программу писал. Утилита для сжатия css-файлов + немного рассказа об регулярных выражениях.

Хороший сайт – быстрый сайт. Надеюсь, никто спорить не будет? В борьбе за каждый байт и секунду одним из наиболее привычных способов ускорения (честно говоря, я просто предпочитаю выполнять gzip сжатие возвращаемого с сервера контента и ничего более) является использование css и javascript упаковщиков. В основе их работы лежит тот факт, что написанный вами красивый и удобочитаемый javascript или css-код:
  1. /* це стиль для левой колонки сайта */
  2. .foo{
  3.   padding: 3px;
  4.   /*
  5.    TODO: спросить у Васи чем ему не нравится красный цвет шрифта
  6.   */
  7.   color: red;
  8. }
Этот код для браузера абсолютно идентичен такому же фрагменту, но без комментариев, без отступов и переходов на новую строку. Ну что возьмешь с этой глупой машины… 🙂

И существует много, нет, не так, великое множество всевозможных утилит “уплотнителей и сжимателей” которые делают это. Найдется и множество сайтов, на которых вы, введя на специальной странице в html-форму текст вашего css или js-файла, получите на выходе сжатую версию оного.

А самое удобное - это когда тебя просят чуть-чуть подправить сайт (ну это делов-то на полчаса, не более) в котором все js и css файлы были так “улучшены”. Честно говоря, это не проблема т.к. есть и утилиты, выполняющие обратный процесс “развертывания” css и js-кода, но очевидно, что это развертывание вернет вам только форматирование, но не комментарии. Ага, а еще мне дают самые полные и точные версии (с документацией, схемами, описаниями моделей БД) на дискетке. К сожалению, часто бывает так, что в условиях постоянного аврала самая последняя (и главное хоть как-то рабочая) версия находится на хостинге и только на нем. А дискетка …? Забудьте!

Я сам (в тех случаях, когда вопрос производительности работы создаваемого сайта не критичен) предпочитаю динамическую генерацию css и js-файлов на основании полных (с комментариями, исходниками) версий. Например, так:
  1. <script src=”js.php?files=main.js,about.js,for_ie_fixes.js”></script>
Внутри этого файла выполняется слияние всех запрошенных js-файлов в один, затем из результирующего файла удаляются комментарии, лишние пробелы и табуляторы, можно выполнить “запутывание” кода, заменив имена переменных на более короткие и не понятные (дабы конкуренты не похитили ваш “всем очень нужный код”). И как “финальный проход” сжать возвращаемый супер js или супер css-файл с помощью gzip. Если вы все не изобрели свой подобный велосипед, то в ходе творчества не забудьте выполнять кэширование. Так очевидно, что если оригинальные файлы не были изменены, то каждый раз выполнять столь дорогостоящий процесс как уплотнение не слишком умно.

Но сегодняшняя заметка немного не о том. Пару дней назад у меня было немного свободного времени, и я решил тряхнуть стариной и написал на java небольшую утилиту, которая сжимает css-файлы. А почему тряхнул стариной? Так уже довольно давно не писал я java-кода работающего с regexp-ами – “пламенным мотором” этой программки – вспомнить довольно приятно. А результаты своего “графоманства” выложил на всеобщий обзор, не забыв снабдить комментариями. Ссылка на конечные исходники лежит в самом конце страницы, а пока поехали. Если вы не знаете, кто такие regexp-ы и ни разу не писали код на java, то вам лучше было бы посетить более специализированные ресурсы.

Возможные способы сжатия я подсмотрел на сайте ?.? (никак не могу найти ссылку, но потеря не велика, как я говорил ранее подобных веб-приложений тьма тьмущая).

Внешний вид программки я набросал в intellij idea. Конструктор форм там довольно приятный и я все чаще пользуюсь ним, а не создаю gui-приложений “ручками”.



Интерфейс довольно прост: вы должны указать тот файл css или каталог с файлами css, которые хотите обработать. Затем нужно указать в какой кодировке файлы следует читать (падающий список для “input character set”). Затем кроме указания того, куда нужно сохранить результат работы (новое имя файла или каталог, куда будут помещены файлы css после обработки) следует задать и кодировку выходных файлов.

Затем вы должны снять отметку с checkbox “skip compress (only change charset)”. Если эта опция включена, то файлы будут скопированы в новое местоположение и у них будет заменена кодировка, не более.

В java есть встроенная поддержка регулярных выражений со времен jdk 1.4. Это где-то лет пять, начиная с 2002 г. Поддержкой regexp-ов занимается пакет java.util.regex. В состав его входят классы: Pattern и Matcher. Первый из них содержит код, который получает при создании объекта Pattern код регулярного выражения, компилирует его, и если вы где-то ошиблись, то генерирует исключение PatternSyntaxException:
  1. try{
  2.  Pattern p = Pattern.compile("xyz");
  3. }catch (PatternSyntaxException e){
  4.  // что то поломалось
  5. }
Созданный объект Pattern использует как параметр при работе объекта Matcher. Хотя для первого задания (удаления дублирующихся пробелов) мне эти два класса явно (я подчеркиваю: явно) пока не нужны. Но по мере того как нужно будет писать более сложные правила преобразования к их использованию я еще вернусь. Пока же запомните, что в состав класса String были введены несколько полезных функций работающих с регулярными выражениями. Самая простая из них это matches, вот ее прототип:
  1. public boolean matches(String regex)
Вы подаете на вход этому методу строку регулярного выражения и вам вернется булева переменная – признак того, что в строке от имени которой был вызван метод, действительно содержится фрагмент совпадающий с регулярным выражением:
  1. if (cssFileContent.matches(‘vasyano petrovno’)){
  2.  // да этот файл написал Вася
  3. }
Хотя, опять-таки поиск в строке некоторого фрагмента (matches) нам не очень полезен, ведь задача стоит как: найти “нечто” и заменить это “нечто” на “нечто другое”.

Опции сжатия делятся на следующие группы:

1.Обработка пробелов и табуляторов:



Первое правило в данной группе – это (replace multiply spaces just one space) замена нескольких подряд идущих пробелов одним.

Давайте разберем, как это сделать.

По правде говоря, в составе класса String есть функция replace. Но ее отличие в том, что мы должны явно указать ту строку, которую хотим заменить на другую. Например, так:
  1. s = “vasyano  petrovno is cool user”.replace (‘cool’, ‘bad’);
Это значит, что мы не используем регулярное выражение и не можем сказать “любое количество пробелов большее чем 1”. Хотя можно сделать так:
 * Заменить в строке два пробела на один
 * Повторить предыдущее действие до тех пор, пока в строке не останется двойных пробелов. 
Но делать мы так не будем, ведь это громоздко и неудобочитаемо, в любом случае для более сложных задач обойтись без regexp-ов не возможно (здесь и везде далее, в переменной sin хранится исходная строка и в нее помещается результат преобразования). Для почти всех следующих задач поиска и замены мы будем использовать фунциую replaceAll. У нее два параметра: что искать (регулярное выражение) и на что заменить (здесь тоже есть специальные “значки”, но об этом позже).
  1. sin = sin.replaceAll("\\s{2,}", " ");
Обратите внимание, что первый параметр задает тот фрагмент исходной строки который нужно найти, а второй на что его заменить. Фигурная скобка имеет особое значение – открывает секцию управления количеством повторений, так строка “x{1,5}” значит, что символ x в исходной строке должен встретиться от одного до пяти раз. А символ “\s” означает пробел. Т.к. в java символ / является особым, то для того чтобы применить его в regexp-е мне пришлось “экранировать” его, добавив второй обратный слэш.

При написании регулярных выражений советую использовать встроенный в idea plugin “Regexp”.



Следующая опция сжатия: “Remove Space Around Chars ;:{},”. Откровенно говоря, я был в небольшом сомнении относительно полезности данного режима, т.к. в уголках памяти “билась” мысль, что по стандарту html наличие пробелов по краям фигурных скобок является обязательным. Но, проверив быстренько работу получающихся файлов в ie, я решил, что, наверное, ошибся, искать нужную статью было откровенно лень.

Итак, для этого режима я использовал следующий regexp. Обратите внимание на то, что перед открывающей фигурной скобкой я поставил два обратных слэша.
  1. //Remove space around chars ;:{},
  2.   sin = sin.replaceAll(" \\{", "{");
  3.   sin = sin.replaceAll(" }", "}");
  4.   sin = sin.replaceAll("\\{ ", "{");
  5.   sin = sin.replaceAll("} ", "}");
  6.  
  7.   sin = sin.replaceAll(" ,", ",");
  8.   sin = sin.replaceAll(", ", ",");
  9.  
  10.   sin = sin.replaceAll(" :", ":");
  11.   sin = sin.replaceAll(": ", ":");
  12.  
  13.   sin = sin.replaceAll(" ;", ";");
  14.   sin = sin.replaceAll("; ", ";");
Затем подумал, что это довольно громоздко и решил воспользоваться оператором или. В regexp-е я могу записать несколько символов, разделив их с помощью “|”, это будет значит что здесь встречается любой из этих символов.
  1. <table class="table-mediawiki" String x  = "(\\{|}|,|:|;)";>
  2. 	<tr>
  3. 		<td sin = sin.replaceAll(x+" ", "$1");
  4. sin = sin.replaceAll(" "+x, "$1");
Для удобства я разбил замену на два действия, хотя можно было обойтись и одним. Первый шаг служит для замены x+”пробел”. Значение же вспомогательной переменной x представляет собой перечисленные через “ >” символы, по краям которых следует удалить пробелы. Обратите внимание на то, что я заключил их внутрь круглых скобок, это необходимо для того, чтобы иметь возможность во втором параметре функции replaceAll указать спец.символ “$1”. Что значит замени найденный фрагмент (любые символы и пробел по краям) на то, что было найдено, но без пробела. $1 – первое, что заключено в круглые скобки, $2 – второе и т.д.

Третий шаг преобразования: “Leave Spaces Between Selectors”. Это значит, что если в исходном css-файле найдется следующий фрагмент:
  1. body,td,p,input { /*здесь между селекторами body, td, p, input НЕТ ПРОБЕЛОВ*/
То, вот его следует разделить пробелами вот так:
  1. body, td, p, input{/* а теперь пробелы есть*/
Итак, для данного преобразования мне пришлось написать следующую строку:
  1. sin = sin.replaceAll("(?is)(,)(?!\\s)(?!\")", ", ");
В самом начале я указал директиву “(?is)”, это значит, что в последующем при записи регулярного выражения мне не важно в каком регистре будут встречаться символы (опция “i” – хотя, она как раз и не используется) и то, что строка будет воспринята как “single line” или “dotall mode” (кому как привычнее). Зачем нужна эта “single line”? Думаю вы знаете что есть специальный символ “.”. Он означает “все что-угодно”. Так вот это не всегда так: в обычном режиме (не “dot all”) если в середине строки встретится символ перехода на новую строку, то он не проассоциируется с “.”. Режим же “dot all” говорит что и даже “переход на новую строку” это все равно “.”.

Итак я ищу в строке запятую, после которой не должен идти пробел (на отрицание указывает наличие перед \\s специального модификатора (?!) – “не должно быть”). Затем идет не кавычка. Найденное такое совпадение должно быть заменено на запятую и пробел.

Следующий режим сжатия css: “Leave Spaces Between Properties”. Это значит, что если в исходном файле встречается такая комбинация символов:
  1. p {font-face: Arial;font-size: 10px;font-weight: bold;}
то ее следует заменить на:
  1. p {font-face:Arial; font-size:10px; font-weight:bold}
Для этой несложной операции подойдет следующее регулярное выражение:
  1. sin = sin.replaceAll("(?is);(?!\\s)", "; ");
  2. sin = sin.replaceAll("(?is);\\s+}", "}");
Т.е. если в строке встретится символ “;” за которым не идет пробел, то его нужно добавить (замена на “; ”). А если мы встретились с идущей в конце списка правил комбинацией символов: “; }“ то нужно избавиться и от лишнего пробела и от лишней точки с запятой.

Следующее правило css-сжатия: “Remove Tab”. Здесь все очевидно, просто находим символы \t и удаляем их.
  1. sin = sin.replaceAll("\t", "");
Итак, мы закончили правила связанные с удалением двойных пробелов, знаков табуляции. Теперь рассмотрим следующую группу правил: “New Lines Handling” (обработка новых строк).



Первое правило в этой группе: “Leave Lines as they are”. Без комментариев, мы просто ничего не делаем.

Второй вариант “Replace nultiply empty lines just one empty line” значит, что надо найти несколько подряд идущих символов перехода на новую строку и заменить их одним, например, так:
  1. sin = sin.replaceAll("(\n){2,}", "\n");
Третий вариант обработки “remove all news lines”. Еще проще предыдущего – удалить все символы “\n” не важно где и сколько их.
  1. sin = sin.replaceAll("\n", "");
Третья группа параметров сжатия посвящена обработке комментариев.



Первый вариант снова самый простой “don’t strip any comments” – комментарии должны остаться без изменений.

Второй режим “strip all comments” требует того, чтобы мы удалили все комментарии из файла, делаем:
  1. sin = sin.replaceAll("(?is)/\\*.*?\\*/", "");
Ничего сложного: мы находим пару символов начала комментария “/*” (не забудьте, что символ “*” является специальным и поэтому мне пришлось его экранировать двумя обратными слэшами). Затем в строке может находиться любое множество любых символов, за которыми должны идти снова звездочка (опять экранируем) и закрывающий слэш. Нет стоп, что-то не так. Представьте себе такую ситуацию:
  1. /* vasya is cool */
  2. P {color: red;}
  3. /* lena is cool */
В регулярных выражениях есть понятие “жадности”. “Жадность” говорит: всегда бери от жизни все, живешь один раз, не думай о других. Проще говоря, мы захватим и удалим не два комментария по отдельности, а абсолютно все содержимое файла. У нас начало комментария – будет начало первого из комментариев, а конец комментария – будет концом последнего комментария. Поэтому я написал так: “.*?”. Поставив после символа “*” (то, что написано до звездочки может встречаться любое количество раз) символ “?” я сменил режим работы regexp-а на “нежадный”. Значит, мы ищем не наиболее длинное совпадение, а наоборот наиболее короткое. Т.е. первый комментарий находим и удаляем, стили для “p” остаются без изменения и удаляется второй комментарий.

Третий режим работы “Strip Comments at least X chars long” значит что нужно удалить лишь те комментарии, длина которых превосходит X символов (X – вводится в текстовое поле). Честно говоря, тут я написать regexp не смог, точнее смог, но на ряде тестов он провалился. Это ситуации, когда идет несколько подряд расположенных очень коротких комментариев по 2, 3 буквы. Так что мне пришлось сделать так:
  1. //Replace multiple empty lines with just one empty line
  2. //первая строка тривиальна – в переменную pi помещается значение
  3. //предельной длины комментария из конфига.
  4. Integer pi = Integer.parseInt(compset.getProperty(VALUE_FOR_STRIP_COMMENTS_AT_LEAST_CHARS_LONG));
  5. StringBuffer sb = null;
  6. // sb – буфер, куда будет накапливаться результат преобразования
  7. Pattern p = null;
  8. Matcher m = null;
  9. try {
  10. // компилируем regexp, который ищет все комментарии без учета их длины
  11. p = Pattern.compile("(?is)/\\*.*?\\*/");
  12. m = p.matcher(sin);
  13. // создаем объект Matcher, с помощью которого затем будет организован
  14. // цикл перебора всех найденных (подошедших под шаблон) строк-коментариев.
  15. sb = new StringBuffer();
  16. while (m.find()) {
  17. // функция find ищет в исходной строке очередной подошедший для regexp-а фрагмент,
  18. // но как только таких совпадений больше нет, то цикл будет прекращен
  19. try {
  20. String gr_0 = m.group(0);
  21. // все группы (части regexp-а заключенные в круглые скобки) могут быть доступны
  22. // с помощью функции group(номер_группы).
  23. // Есть особый номер группы – 0 – эта группа захватывает абсолютно весь текст строки,
  24. // который проассоциировался с регулярным выражением
  25. if (gr_0.length() - 4 <= pi)
  26. // проверяем, что если длина этого комментария за вычетом четырех символов
  27. //(два знака “*” и два знака “/”) все же не смогла превзойти предельную то в буфер sb помещается комментарий
  28. m.appendReplacement(sb, gr_0);
  29. //else
  30. // иначе комментарий удалется
  31. // m.appendReplacement(sb, "");
  32. } catch (Exception e) {
  33. e.printStackTrace();
  34. }
  35. }// end of -- while --
  36. // завершаем обработку хвоста исходной строки – после последнего совпадения с regexp
  37. m.appendTail(sb);
  38. } catch (Exception e) {
  39. e.printStackTrace();
  40. }
  41. sin = sb.toString();
Теперь разберем последний набор параметров сжатия: “Other Options” (всякая всячина которая не вошла в предыдущие пункты).



Первое правило “compress color codes where possible”. Этот режим сжатия основан на том, что если значение некоторого css-свойства задающего цвет равно, например:
  1. #FF0088 , #FF0000 , #FC0080 , #FFFFFF
То это можно записать более компактно:
  1. #F08 , #F00 , #FC0080 , #FFF
Т.е. пары идущих рядом одинаковых символов (0-9 и A-F) могут быть заменены на всего один символ.
  1. sin = sin.replaceAll("([A-Fa-f0-9])\\1([A-Fa-f0-9])\\2([A-Fa-f0-9])\\3", "$1$2$3 ");
Здесь я использовал прием с квадратными скобками и обратными ссылками. Запись [A-Fa-f0-9] означает то, что здесь может быть расположен один, но совершенно любой символ из следующих диапазонов (0-9 и a-f). Обратная ссылка “\1” значит, что сюда нужно подставить то значение, которое было помещено внутрь круглых скобок под номером 1. И так я сделал для всех трех возможных групп.

Следующее правило сжатия: “One Command Per Line”. В том случае если в исходной строке встретится нечто вроде:
  1. p {
  2. font-face: Arial;
  3. font-size: 10px;
  4. font-weight: bold;
  5. }
То его нужно заменить на:
  1. p {
  2. font-face:Arial;font-size:10px;font-weight:bold
  3. }
Для этого я использую следующий код:
  1. StringBuffer sb = null;
  2. // буфер куда будет помещен результат преобразования
  3. Pattern p = null;
  4. Matcher m = null;
  5. try {
  6. // в этом шаблоне я использую следующую запись:
  7. // найти “начало строки” (за это отвечает запись “^” и для того, чтобы она корректно работала
  8. // мне пришлось включить режим “?m” - multiline). Возможно идет несколько знаков табуляции за
  9. // которым должены идти любые символы (возможно css-свойство состоит из нескольких селекторов),
  10. // затем символ “{”, снова любые символы и, наконец, “}”.
  11. p = Pattern.compile("(?ims)^[\\t ]*(.*?)(\\n?)\\{(.*?)(\\n*)\\}");
  12. m = p.matcher(sin);
  13. sb = new StringBuffer();
  14. while (m.find()) {
  15. try {
  16. String gr_1 = m.group(1);
  17. String gr_3 = m.group(3);
  18. // найденные фрагменты добавляются к sb, но предварительно
  19. // я избавляюсь от знаков перехода на новую строку
  20. m.appendReplacement(sb, gr_1.replace("\n", "") + "{" + gr_3.replace("\n", " ") + "}\n");
  21. } catch (Exception e) {
  22. e.printStackTrace();
  23. }
  24. }//end -- while --
  25. m.appendTail(sb);
  26. } catch (Exception e) {
  27. e.printStackTrace();
  28. }
  29. sin = sb.toString();
https://github.com/study-and-dev-site-attachments/all-in-one/tree/master/java/csscompactor