Running with Ruby

Tag: Base64 (page 2 of 2)

Osadzanie obrazów w CSS z pomocą Base64, Rubiego i Railsów

Pracując nad Senpuu v5 zauważyłem, że ilość teł i innych obrazków wkomponowanych w arkusze styli (CSS) niebezpiecznie rośnie. Standardowo osadzanie grafik wygląda (mniej więcej) tak:

div#id {
  background: url(/images/background.png) repeat-x;
}

Kiedy ładujemy stronę po raz pierwszy (i nie jest ona w cache’u), przeglądarka odpytuje o każdy plik z osoba – co w przypadku Senpuu v5 – daje obecnie ponad 47 żądań. Jest to trochę dużo, zwłaszcza że duża część plików jest stosunkowo mała (1kb-3kb), w skutek czego tracimy bardzo dużo czasu i mocy obliczeniowych na tego typu żądania.

Aby zminimalizować ilość żądań, możemy zastosować jedną z dwóch metod:

  • CSS Sprite’s
  • base64-encoded Data URIs

O ile z pierwszej z nich korzystałem dawniej, o tyle nie przypadła mi do gustu (wkurzające osadzanie w CSSie obrazków, niewygodna rozbudowa plików z tłami). Druga metoda jednak (base64-encoded Data URIs) bardzo przypadła mi do gustu. W skrócie, polega ona na osadzaniu obrazków bezpośrednio w pliku CSS. Jak to się robi? Bardzo prosto:

div#id {
  background: url(data:image/png;base64,OBRAZEK_W_BASE64) repeat-x;
}

Zalet takiego rozwiązania jest wiele:

  • Mniejsza (często dużo np z 47 do 14) ilość żądań do serwera;
  • Inny sposób renderowania strony – “wskakuje” cała od razu;
  • Brak czasochłonnego doczytywania w tle obrazków;
  • Korzystając z GZipa – kompresja styli.

Nie ma jednak róży bez kolców, tak więc są i minusy:

  • IE7 i IE6 nie obsługują tej metody osadzania obrazków (ale kto by się tam tym przejmował ;) );
  • Osadzanie “ręcznie” jest stosunkowo niewygodne – CSS staje się “szeroki” i źle się z nim pracuje;
  • IE8 obsługuje osadzanie tylko jeśli grafiki mają mniej niż 32kb (ale to od biedy da się przeżyć);
  • Konwersja do Base64 zwiększa rozmiar grafik o ponad 30% – jednak kompresując GZipem arkusze różnica jest minimalna.

Tak jak wspomniałem powyżej, osadzanie grafik jest bardzo niewygodne, dla przykładu – tak wygląda osadzenie tła (wybrałem stos. mały plik a i tak go “złamałem”):

div#id {
  background: #263E55 url(data:image/png;base64,iVBORw
    0KGgoAAAANSUhEUgAAAAEAAAAZCAIAAAB/8tMo
    AAAACXBIWXMAABv9AAAb/QGX0Qc2AAAAMUlEQ
    VR42kWLuREAIAzDdN6JRZiE/YuIgnCpLH+sfYJGeK
    qRGtZY31dno3T/t/R/mAtRMDvkNuHCUwAAAABJRU
    5ErkJggg==) repeat-x;
}

Taki kod (zwłaszcza jak ktoś ma w IDE włączone łamanie wierszy) bardzo utrudnia rozczytanie CSSa. Osadzanie plików jest proste – można skorzystać z jednego z konwerterów do i z base64. Na dłuższą metę jest to jednak niewygodne, ponieważ jeśli zmienimy plik, musimy go od nowa przekonwertować i wstawić w arkusz. Z tego względu opracowałem małą klasę i metodę pomocniczą dla Railsów. Klasa ta konwertuje arkusz styli, zamieniając “zewnętrzne” obrazki na osadzone. Metoda pomocnicza umożliwia umieszczanie arkuszy styli na stronie, osadzając obrazki w CSSie tylko w pliku podlegającym cachowanie (nie w oryginalnych CSSach), tak aby nie “uszkodzić” naszych oryginalnych plików CSS.

Całość jest niewielka – rozbija się w zasadzie o regexpy. Poniżej kod i jego analiza. Najpierw zajmiemy się helperem który wygląda tak:

  # Musiałem to przepisać i dodać konwersję z teł osadzanych z zewnątrz
  # na osadzane w base64 (pod warunkiem że ważą mniej niż 32kb)
  def base64_stylesheet_link_tag(*sources)
    options = sources.extract_options!.stringify_keys
    concat  = options.delete("concat")
    cache   = concat || options.delete("cache")
    recursive = options.delete("recursive")

    if concat || (config.perform_caching && cache)
      joined_stylesheet_name = (cache == true ? "all" : cache) + ".css"
      joined_stylesheet_path = File.join(joined_stylesheet_name[/^#{File::SEPARATOR}/] ? config.assets_dir : config.stylesheets_dir, joined_stylesheet_name)

      unless config.perform_caching && File.exists?(joined_stylesheet_path)
        write_asset_file_contents(joined_stylesheet_path, compute_stylesheet_paths(sources, recursive))
        css = CssConverter.new(
          :path => joined_stylesheet_path,
          :overwrite => true,
          :root => File.join(Rails.root, 'public'))
        css.convert!
        css.save!
      end
      stylesheet_tag(joined_stylesheet_name, options)
    else
      sources = expand_stylesheet_sources(sources, recursive)
      ensure_stylesheet_sources!(sources) if cache
      sources.collect { |source| stylesheet_tag(source, options) }.join("\n").html_safe
    end
  end

Kod ten nie będzie (w większości) podlegał wyjaśnieniu, ponieważ nie licząc 3 wierszy kodu, reszta to dokładna kopia metody stylesheet_link_tag.

Trzy wiersze o których wspominałem wyżej to:

        css = CssConverter.new(
          :path => joined_stylesheet_path,
          :overwrite => true,
          :root => File.join(Rails.root, 'public'))
        css.convert!
        css.save!

Najpierw tworzę obiekt klasy CssConverter, podając mu ścieżkę do cachowanego przez Railsy (linijkę wyżej ;) ) pliku joined_stylesheet_path, informuję Railsy że mają nadpisać arkusz styli moim z osadzonymi obrazkami (:overwrite => true) oraz podaję ścieżkę domyślną, skąd obiekt ma pobierać obrazki (:root => File.join(Rails.root, ‘public’)). Następnie konwertuję i zapisuję wygenerowany plik CSS.

Warto zauważyć, że całość konwersji i zapisu dokonuje się tylko jeśli plik CSS nie był cachowany (i cachowanie jest w ogóle włączone):

unless config.perform_caching && File.exists?(joined_stylesheet_path)

Dzięki temu, zamiana na base64, osadzanie w CSSie i zapis odbywają się tylko podczas pierwszego żądania arkusza.

Zanim przejdziemy do omawiania klasy konwertującej, zdefiniujmy sobie dwa wyjątki (omówienie w komentarzu nad wyjątkiem):

module Exceptions
  # Podany plik z CSSem do przerobienia nie istnieje
  class CssNotFound < StandardError; end

  # Nie podano ani pliku ani styli bezpośrednio
  # (czyli nie ma z czego robić konwersji)
  class CssStringNotProvided < StandardError; end
end

Klasa CssConverter zawiera łącznie 5 metod:

  1. initialize(*sources) – do wywoływana CssConverter.new,
  2. convert! – dokonuje konwersji,
  3. save! – zapisuje,
  4. (private) pull – “wyciąga” ścieżki do obrazków z oryginalnego CSSa,
  5. (static) file_to_base64 – zamienia plik spod podanej ścieżki na base64.

Na początek szkielet obiektu:

require 'base64'

class CssConverter
  DEFAULT_MAX_IMG_SIZE = 32*1024

  attr_reader :result
end

Maksymalny rozmiar pliku jaki będzie osadzany to 32kb. Dajemy sobie też możliwość odczytania wyniku bezpośrednio z obiektu.

Metoda inicjalizacyjna:

  def initialize(*sources)
    options = sources.extract_options!.stringify_keys
    source  = options.delete("source")
    @file_path = options.delete("path")
    @root_path = options.delete("root")
    @overwrite = options.delete("overwrite")
    @result = nil

    @img_max_size = DEFAULT_MAX_IMG_SIZE || options.delete("img_max_size")

    if @file_path
      raise Exceptions::CssNotFound unless File.exists?(@file_path)
      @source = File.open(@file_path, 'r') { |file| file.read }
    else
      raise Exceptions::CssStringNotProvided unless source
      @source = source
      @overwrite = false
    end
  end

Pobieramy w niej potrzebne nam parametry oraz sprawdzamy czy mamy “z czego” robić konwersję CSSa (czy dano nam jakieś dane). Parametry jakie wprowadzamy to:

  • path – ścieżka do źródłowego pliku CSS (wraz z rozszerzeniem!) z którego mamy konwertować,
  • source – lub ewentualnie string zawierający CSS który mamy poddać konwersji,
  • root – katalog z którego zaczynamy przeszukiwanie plików z obrazami do osadzenia,
  • overwrite – czy przy zapisie mamy nadpisać nasz źródłowy plik CSS z którego pobieraliśmy dane.

Inicjalizujemy sobie zmienne instancyjne, sprawdzamy czy jest z czego konwertować (jak nie to wyrzucamy wyjątek).

Zajmijmy się teraz bodaj najważniejszą metodą, metodą prywatną pull:

  # Wyciąga urle - i je w razie czego poprawia
  # Zwraca tablice [[:original, :converted]]
  def pull
    # Całość wraz z url czyli np url('/images/cos.png')
    whole_urls = @source.scan(/url\(.+\)/i)

    return nil if whole_urls.count == 0

    # Sama scieżka czyli np /images/cos.png
    img_bg_paths = []
    whole_urls.each do |url|
      img_bg_paths << url.scan(/url\(\s*["']?([^"']+)["']?\s*\)/i)[0][0]
    end
    img_bg_paths_original = img_bg_paths.clone

    img_bg_paths.collect! do |img|
      img = img.gsub('./', '/')
      img = "/#{img}" if img.first != '/'
      img
    end

    result = []
    img_bg_paths.each_with_index do |el, i|
      result <<
        {:original_path =>img_bg_paths_original[i],
        :converted_path => el,
        :original_url => whole_urls[i]
        }
    end
    result
  end

W metodzie tej wyodrębniamy najpierw cały fragment z CSSa zawierający ścieżkę:

url(/images/plik.png)

Potrzebujemy całości, ponieważ zostanie ona zamieniona na nowy osadzony fragment. Potrzebujemy też samą ścieżkę do pliku, co wyodrębniamy drugim regexpem. Następnie zamieniamy ew. odniesienia względne na bezwzględne i dodajemy ukośnik na początek (jeśli nie było). Potem całość zgrabnie pakujemy do jednej tablicy i zwracamy jako wynik. Po odpaleniu tej metody, mamy więc ścieżki do plików, oraz fragmenty które musimy naszym regexpem zamienić. Nie pozostaje nam nic innego jak zamienić co trzeba ma base64 i umieścić w naszym arkuszu.

Aby zamieniać pliki na base64 wykorzystamy metodę statyczną file_to_base64:

  # Zamienia plik na base64
  def self.file_to_base64(path)
    str = File.open(path, 'r') { |file| file.read }
    Base64.encode64(str).gsub("\n", '')
  end

Standardowa metoda z Rubiego – encode64 zwraca stringa ze znakami nowych wierszy (taka naleciałość), z tego względu jest na końcu gsub.

Konwersja jest całkiem prosta – przelatujemy gsubem “starego” CSSa i zamieniamy konkretne fragmenty na nasze nowe, osadzone. Następnie rezultat przypisujemy do zmiennej instancyjnej:

  # Konwertuj zadany styl osadzając w nim obrazki w base64
  def convert!
    if elements = pull
      source = @source
      elements.each do |el|
        file_base64 = File.join(@root_path, el[:converted_path])
        file_ext = el[:converted_path].split('.').last
        begin
          next if File.size?(file_base64) > @img_max_size
          file_base64 = self.class.file_to_base64(file_base64)
          source.gsub!(el[:original_url], "url(data:image/#{file_ext.downcase};base64,#{file_base64})")
        rescue
          next
        end
      end
      @result = source
    else
      @result = @source
    end
  end

Warto zauważyć, że chronimy się także przed ew. brakiem pliku do odczytu. Jeśli pliku nie ma, to po prostu pozostawiamy to tło, takim jakie jest. To samo tyczy się rozmiaru pliku – jeśli jest za duży to go nie wrzucamy do arkusza.

Pozostaje nam tylko bajecznie prosty zapis:

  # Zapisuje do pliku
  def save!(filename = nil)
    if @file_path && @overwrite && filename.nil?
      p = @file_path
    else
      p = filename
    end
    f = File.new(p, "w")
    f.write(@result)
    f.close
  end

Koniec. Całość wygląda tak:

require 'base64'

class CssConverter
  DEFAULT_MAX_IMG_SIZE = 32*1024

  attr_reader :result

  def initialize(*sources)
    options = sources.extract_options!.stringify_keys
    source  = options.delete("source")
    @file_path = options.delete("path")
    @root_path = options.delete("root")
    @overwrite = options.delete("overwrite")
    @result = nil

    @img_max_size = DEFAULT_MAX_IMG_SIZE || options.delete("img_max_size")

    if @file_path
      raise Exceptions::CssNotFound unless File.exists?(@file_path)
      @source = File.open(@file_path, 'r') { |file| file.read }
    else
      raise Exceptions::CssStringNotProvided unless source
      @source = source
      @overwrite = false
    end
  end

  # Konwertuj zadany styl osadzając w nim obrazki w base64
  def convert!
    if elements = pull
      source = @source
      elements.each do |el|
        file_base64 = File.join(@root_path, el[:converted_path])
        file_ext = el[:converted_path].split('.').last
        begin
          next if File.size?(file_base64) > @img_max_size
          file_base64 = self.class.file_to_base64(file_base64)
          source.gsub!(el[:original_url], "url(data:image/#{file_ext.downcase};base64,#{file_base64})")
        rescue
          next
        end
      end
      @result = source
    else
      @result = @source
    end
  end

  # Zapisuje do pliku
  def save!(filename = nil)
    if @file_path && @overwrite && filename.nil?
      p = @file_path
    else
      p = filename
    end
    f = File.new(p, "w")
    f.write(@result)
    f.close
  end

  # Zamienia plik na base64
  def self.file_to_base64(path)
    str = File.open(path, 'r') { |file| file.read }
    Base64.encode64(str).gsub("\n", '')
  end

  private

  # Wyciąga urle - i je w razie czego poprawia
  # Zwraca tablice [[:original, :converted]]
  def pull
    # Całość wraz z url czyli np url('/images/cos.png')
    whole_urls = @source.scan(/url\(.+\)/i)

    return nil if whole_urls.count == 0

    # Sama scieżka czyli np /images/cos.png
    img_bg_paths = []
    whole_urls.each do |url|
      img_bg_paths << url.scan(/url\(\s*["']?([^"']+)["']?\s*\)/i)[0][0]
    end
    img_bg_paths_original = img_bg_paths.clone

    img_bg_paths.collect! do |img|
      img = img.gsub('./', '/')
      img = "/#{img}" if img.first != '/'
      img
    end

    result = []
    img_bg_paths.each_with_index do |el, i|
      result <<
        {:original_path =>img_bg_paths_original[i],
        :converted_path => el,
        :original_url => whole_urls[i]
        }
    end
    result
  end
end

Poszczególne pliki do pobrania:

Rozsyłanie poczty e-mail wraz z osadzonymi obrazkami (base64)

Rozsyłając e-maile, niejednokrotnie chcielibyśmy je uatrakcyjnić. Sam suchy tekst nie jest ani ładny, ani zbyt wymowny. Najprostszym sposobem na uatrakcyjnienie naszych wiadomości e-mail, jest zastosowanie w nim zamiast suchego tekstu (text/plain) – HTMLa (text/html). Niesie to za sobą jednak kilka problemów:

  • Nie wszystkie readery obsługują HTML (aczkolwiek te nieobsługujące to raczej wyjątki)
  • Nie wszystkie readery obsługują CSS i osadzanie obrazów (np. google reader – aczkolwiek nie zawsze ;) )
  • Część readerów obsługuje tylko część znaczników i selectorów

Jak więc sprawić żeby nasze e-maile były ładne tam gdzie się da oraz czytelne tam gdzie trzeba?

Nie jest to wbrew pozorom aż tak trudna sprawa. Pierwszą rzeczą jaką należy przygotować jest tekst. W naszym przypadku posłużymy się tekstem który już mam. Dotyczy on zmiany adresu e-mail w Susanoo.

Cała wiadomość wygląda tak:

Szanowny Użytkowniku,

Niniejszy e-mail wysłany został ze względu na rozpoczętą procedurę zmiany adresu e-mail konta: XYZ. Jeżeli nie wykonywałeś takiej dyspozycji – zignoruj tę wiadomość.

Aby dokończyć operację, potwierdź ją: potwierdzam
Możesz także skorzystać z poniższego odnośnika, kopiując go do swojej przeglądarki internetowej:

http://localhost:3000/admin/core/account/confirm/jakistamhash

Jeśli nie aktywujesz nowego adresu e-mail w przeciągu 24 godzin, system automatycznie przywróci Twój poprzedni adres e-mail.

Oczywiście tutaj jako cytat wygląda beznadziejnie. Sprawimy więc żeby jako e-mail wyglądała tak jak trzeba. Nie opiszę tutaj całego procesu tworzenia takiego “ładnego” e-maila. Opiszę jednak metodologię abyście sami mogli rozwijać własne wzory e-maili.

Zanim zaczniemy, wyznaczmy sobie cele. Chcemy aby w readerze który jest dość słaby, wiadomość wyglądała tak:

zrzut_ekranu

Jak widać, jest nagłówek, zwrot grzecznościowy oraz treść. Na końcu i na początku są też notki dot. tego że wiadomość rozsyłał automat. Jest przejrzyście i prosto. Ale z drugiej strony, w readerze obsługującym osadzanie styli oraz obrazki w  base64, ta wiadomość wygląda tak:

zrzut_ekranuww

Jak widać, wiadomość prezentuje się całkiem nieźle. Jak osiągnąć taki podwójny efekt? Przede wszystkim należy pamiętać że Gmail nie wspiera ani osadzania CSSa w plikach zewnętrznych, ani też w headerze strony. Jedynym wyjściem są proste style inline. I tak właśnie postąpiłem w jego wypadku. Skorzystałem z prostych styli jak:

  • color
  • padding
  • margin
  • text-decoration
  • font-size

Aby nadać mu podstawowy wygląd. Jednocześnie skorzystałem z pewnej sztuczki. Aby nasze style które osadzimy dla “fajniejszych” readerów, nie gryzły się z tymi dla słabych, należało tak utworzyć CSS aby nie kolidował jeden z drugim. Było to stosunkowo proste. O ile w przypadku Gmaila styl inline wyglądał tak:

<div id="confirm_contener" style="margin-top: 20px; margin-bottom: 20px; padding: 20px 10px;">

O tyle już CSS “normalny” był taki:

padding-bottom:  20px;

Jak widać jest to podobny kod, jednak inline ustawia marginesy tylk górny i dolny, zaś nasz “CSSowy” ustawia je wszystkie. W ten sposób uzyskujemy wcięcie z każdej strony. Większość pracy właśnie polega na utworzeniu niekolidujących ze sobą styli, co tak naprawdę nie jest trudne o ile najpierw tworzymy styl zaawansowany który umieszczamy w headerze strony jako:

<style type="text/css">
    jakieś style ...
</style>

Dzięki temu wyświetlanie w klientach jak Gmail nastąpi bez uwzględnienia tych styli. Dla takich readerów wstawiamy CSS inline.

Pozostaje jeszcze kwestia grafik. Tutaj są dwie możliwości:

  1. Grafiki zdalne – doczytywane z naszego serwera
  2. Grafiki inline – zakodowane w samej wiadomości

Ja skłoniłem się ku rozwiązaniu drugiemu. Dlaczego? Otóż wiele klientów webmail (jak i część desktopowych) domyślnie nie ładuje grafik zewnętrznych traktując je jako potencjalnie groźną zawartość zdalną. Pojawia się monit z pytaniem czy przeglądarka ma wyświetlić zawartość zdalną. Nie bardzo mi się to podobało i postanowiłem oszczędzić użytkownikom zbędnych pytań.

Osadzam więc te newielkie graficzki jak tło czy przycisk, jako obrazy inline osadzone w ciele dokumentu. Jak tego dokonać?

Mając obrazek, przykładowo obrazek tła, wchodzimy tutaj:

http://www.motobit.com/util/base64-decoder-encoder.asp

I ładujemy tam nasz plik. Serwis ten umożliwia przekonwertowanie dowolnego pliku na jego odpowiednik zakodowany w base64, dzięki czemu możemy go wstawić do kodu inline. Należy przy tym pamiętać ze konwerter ten, dodaje znaki nowego wiersza, więc trzeba je pousuwać (cały “tekst”| wynikowy musi być w jednej linii). Nasz plik po przekonwertowaniu, stanie się “czytelny” i możliwy do wklejenia. Będzie wyglądał mniej więcej tak (tylko będzie tego trochę więcej):

WC1BY2NvdW50LUtleTogYWNjb3VudDQNClgtVUlETDogR21haWxJZDEyYjQ1Z
WJlYWEwZWNiZWQNClgtTW96aWxsYS1TdGF0dXM6IDAwMDENClgtTW96aW
xsYS1TdGF0dXMyOiAwMDAwMDAwMA0KWC1Nb3ppbGxhLUtleXM6ICAgICAg
ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI
CAgICAgICAgICAgICAgICAgICAgICAgICAgDQpEZWxpdmVyZWQtVG86IG1lbn
Nm

Taki “tekst” wstawiamy do kodu. Jeśli chodzi o wstawianie np. tła w CSS, wykonujemy to w taki sposób:

background: url('data:image/gif;base64,R0lGODlhBQAFAJEAABgYGC4uLigoK=');

Jak więc widzicie wystarczy dodać: data:image/gif;base64, a po tym wkleić naszą zwartość. Od tego momentu w wypadku zaawansowanych czytników, będziemy mieli ładne tło dla danego elementu.

Jeśli chodzi zaś o osadzanie elementów klikalnych jak img, sytuacja nie różni się zbytnio od poprzedniej. W przypadku HTMLa wygląda to tak:

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhE">

Wpisujemy w src data:image/png;base64,, po czym naszą zawartość, zamykamy i już. Jest jednak w tym rozwiązaniu jeden minus. Mianowicie jeśli mamy elementy klikalne, które są podlinkowane, musimy dostarczyć alternatywę dla prostych readerów. Tą alternatywą jest alt. Sam obrazek nie będzie widoczny, jednak zamiast niego będzie tekst który osadzimy właśnie w alt. Dodatkowo dodamy jeszcze prosty styl inline aby obrazki nie miały obramowania. Całość będzie wyglądać następująco:

    <a href="www.dev.mensfeld.pl" id="img_confirm">
        <img src="data:image/png;base64,iVBORw0..." alt=" potwierdzam" style="border:0; margin:0; padding: 0"/>
    </a>

To by było na tyle. Dzięki odpowiedniemu ustawieniu stylów w headerze, stylów inline oraz osadzeniu obrazów wraz z atrybutem alt możemy uzyskać fajnie wyglądające wiadomości e-mail, tam gdzie się da, oraz funkcjonalne i przejrzyste tam gdzie wyświetla się tylko tekst oraz ew. podstawowe style inline.

Newerposts

Copyright © 2021 Running with Ruby

Theme by Anders NorenUp ↑