A little scripting to help with HTML email – bringing styles inline

As anyone keeping an eye on my deli.cio.us feed may have noticed, quite a few links have appeared to information about the preparation of HTML email. It’s a nasty business, as a quick glance at the website of the email standards project will tell you. But sadly, nasty as it may be, sometimes it has to be done.

Even if the email I send out is going to have CSS scattered inline, for building the templates I’d much rather be able to focus on writing the structure of the document and leave worrying about my CSS for another time, and another file. That wouldn’t get me around the nastiness of having to use tables for anything but the simplest of layouts, but it still feels right to keep the separation for as long as possible.

I had a quick look for a tool that would take a stylesheet and an HTML document, and embed the rules online, but didn’t find one. So I turned to ruby. In theory it should be very easy to build something like this, because of hpricot‘s support for CSS selectors. If we had the CSS stored in a hash all it would take would be something like:

require 'hpricot'
doc = Hpricot(open('my_page.html')

css_as_hash.each do |selector, rule|
  (doc/selector).set('style', rule)
end

puts doc

Obviously that wouldn’t play nicely if there were already any styles inline, but for the purposes of this project I assumed there wouldn’t be.

I had a quick look at the cssparser rubygem but found that the sample code threw ‘method not found’ errors so I decided to quickly roll my own class that would take a path to a CSS file, and convert it to a hash. All it took was a few minutes’ work and the result was:

# This class takes a CSS file and provides a method to
# parse it into a hash. Usage is:
#
# parser = SimpleCSSParser.new('/path/to/myfile.css')
# hash_of_rules = parser.to_hash
#
# For more advanced CSS handling check out the cssparser gem
# http://code.dunae.ca/css_parser/
class SimpleCSSParser

  # Receive and open the CSS file, storing its contents
  def initialize(path_to_file)
    @css = open(path_to_file).read
  end

  # Convert the CSS into a hash, where the keys are the selectors
  # and the values are the rules
  def to_hash
    @to_hash ||= separate_rules.inject({}) do |collection, rule|
      identifiers, rule = prepare_selectors_and_rule(rule)
      identifiers.each do |identifier|
        collection[identifier] ||= ''
        collection[identifier] += rule
      end
      collection
    end
  end

  private
    def separate_rules
      @css.split('}')
    end

    # Strip comments and extraneous white space from our CSS rules
    def clean_up_rule(css_rule)
      css_rule = css_rule.gsub(/\/\*.+?\*\//, '')
      css_rule.gsub(/\n|\s{2,}/, '')
    end

    # Break apart our selector(s) and rule. We return an array
    # of selectors to allow for situations where multiple selectors
    # are specified (comma separated) for a single rule
    def prepare_selectors_and_rule(rule)
      parts = rule.split('{')
      selectors = parts[0].split(',').map(&:strip)
      return selectors, clean_up_rule(parts[1])
    end
end

With that in place, I can now call:

require 'hpricot'

doc = Hpricot(open('my_file.html'))
parser = SimpleCSSParser.new('my_file.css')

parser.to_hash.each do |selector, rule|
  (doc/selector).set('style', rule)
end

puts doc

and have the result I wanted all along. It’s rather brittle because of the way it splits the rules up, and it won’t pull in @include’d files, handle multiple CSS files, or do anything to honour the proper inheritance rules, but for my purposes that’s okay. I bundled it all up in a file that can be called from the command line. You can find that in this pastie.

A nice (and really quite simple) addition would be to take Campaign Monitor’s Guide to CSS Support in Email, parse it and spit out warnings about which email clients will have issues with which CSS rules. If I get round to implementing that I’ll blog about it here. If you get there before me, do post a comment and let me know.

UPDATE (5th Dec ’07: I’ve posted a follow-up looking at some other Ruby CSS parsers.

Tags: , , , ,

7 comments

  1. James, you should look at CSSPool. It works really nicely with Hpricot, and I think it should be able to replace your home grown CSS parser:

    http://csspool.rubyforge.org/showcase.html

  2. Nice concise, pragmatic solution. I think you can get away without cascading rules on most emails anyway.

    By they, check your openid comment login — wordpress threw a db error for me.

  3. TamTam (http://tamtam.rubyforge.org/) does what you describe in this post. I know. I created a TextMate command that uses it.

  4. Thanks for the suggestions. I’ll give CSSPool and TamTam a try today and post a followup.

    Andre – thanks for the reminder. I just switched OpenID plugins and something seems to have broken along the way. I’ll work on that now.

  5. @batnight – is your TextMate command published anywhere?

  6. Don’t have a blog right now, so here’s the code.


    #!/usr/bin/env ruby

    require 'rubygems'
    require 'tamtam'

    html_doc = STDIN.read
    html_filtered = TamTam.inline(:document => html_doc)
    STDOUT.write html_filtered

    Set the input to “Entire Document” and output to “Replace Document.” If you find this too draconian, then set the output to “Create New Document.”

    Lastly set the scope selector to “text.html” and click on another command to save your new command.

    Pretty simple I say.

  7. Hi,

    Nicely done,

    I had written a ruby script using Hpricot for the same purpose:

    http://tech.bytefull.com/2007/06/03/utility-for-newsletters/