├── test ├── fixtures │ ├── test3-noimport.css │ ├── test-import.css │ ├── test3-import.css │ ├── client_support.html │ ├── test2.css │ ├── test3.css │ ├── test.css │ ├── test.html │ ├── test-with-folding.html │ ├── test2.html │ ├── test3.html │ └── test3-out.html ├── images │ ├── inset.jpg │ └── content_bg.jpg ├── test_helper.rb ├── test_convert_to_plain_text.rb ├── test_premailer.rb ├── speed.rb ├── test_link_resolver.rb └── test_premailer_download.rb ├── CHANGELOG ├── LICENSE ├── README ├── lib ├── html_to_plain_text.rb └── premailer.rb ├── bin └── premailer └── misc └── client_support.yaml /test/fixtures/test3-noimport.css: -------------------------------------------------------------------------------- 1 | h1 { color: #f00 !important; } 2 | -------------------------------------------------------------------------------- /test/images/inset.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloopletech/premailer/master/test/images/inset.jpg -------------------------------------------------------------------------------- /test/fixtures/test-import.css: -------------------------------------------------------------------------------- 1 | body, #container { 2 | color: #fff; 3 | background: #1c2815 none; 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/test3-import.css: -------------------------------------------------------------------------------- 1 | body, #container { 2 | color: #fff; 3 | background: #1c2815 none; 4 | } 5 | -------------------------------------------------------------------------------- /test/images/content_bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloopletech/premailer/master/test/images/content_bg.jpg -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__), '../')) 2 | require 'rubygems' 3 | require 'test/unit' 4 | require 'lib/premailer' 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/fixtures/client_support.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Premailer Test Page 5 | 6 | 7 | /* one of each warning levels should be present */ 8 |

Test page.

9 | 10 | 11 | -------------------------------------------------------------------------------- /test/test_convert_to_plain_text.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/test_helper' 2 | require 'lib/html_to_plain_text' 3 | 4 | class PlainTextTests < Test::Unit::TestCase 5 | include HtmlToPlainText 6 | 7 | def test_unordered_lists 8 | html = %q{ 9 | 10 | 19 | } 20 | text = %q{ * Test 1 21 | * Test 2 22 | * Test 2a 23 | * Test 3} 24 | 25 | assert_equal text, convert_to_text(html, 80) 26 | end 27 | 28 | 29 | 30 | end 31 | -------------------------------------------------------------------------------- /test/test_premailer.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/test_helper' 2 | 3 | 4 | class PremailerTests < Test::Unit::TestCase 5 | 6 | def setup 7 | #@premailer = Premailer.new 8 | end 9 | 10 | def test_escaping_strings 11 | str = %q{url("/images/test.png");} 12 | assert "url(\'/images/test.png\');", Premailer.escape_string(str) 13 | 14 | str = %q{url("/images/\"test.png");} 15 | assert "url(\'/images/\'test.png\');", Premailer.escape_string(str) 16 | 17 | str = %q{url('/images/\"test.png');} 18 | assert "url(\'/images/\'test.png\');", Premailer.escape_string(str) 19 | end 20 | 21 | 22 | end 23 | -------------------------------------------------------------------------------- /test/speed.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__), '../css_parser/')) 2 | $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__), '../')) 3 | 4 | 5 | 6 | 7 | require "benchmark" 8 | require 'css_parser' 9 | 10 | include Benchmark 11 | 12 | 13 | 14 | css_block = {:declarations=>"color: #fff; background: #1c2815 none;", :selector=>"body", :specificity=>1}, 15 | {:declarations=>"color: #fff; background: #1c2815 none;", :selector=>"#container", :specificity=>100} 16 | 17 | n = 10000 18 | 19 | 20 | bm(12) do |test| 21 | test.report("creating ruleset:") do 22 | @cp = CssParser.new 23 | n.times do |x| 24 | @cp.fold_declarations(css_block) 25 | end 26 | end 27 | test.report("parsing in block:") do 28 | @cp = CssParser.new 29 | n.times do |x| 30 | @cp.fold_declarations_old(css_block) 31 | end 32 | end 33 | end 34 | 35 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | = Premailer CHANGELOG 2 | 3 | == Version 0.9 4 | * initial proof-of-concept 5 | * PHP web version 6 | 7 | == Version 1.0 8 | * ported web interface to eRuby 9 | * incremental parsing improvements 10 | 11 | == Version 1.1 12 | * proper calculation of selector specificity per CSS 2.1 spec 13 | * support for @import 14 | * preliminary support for shorthand CSS properties (margin, padding) 15 | * preliminary separation of CSS parser 16 | 17 | == Version 1.2 18 | * respect LINK media types 19 | * better style folding 20 | * incremental parsing improvements 21 | 22 | == Version 1.3 23 | * separate CSS parser into its own library 24 | * handle background: red url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP4%2F58BAAT%2FAf9jgNErAAAAAElFTkSuQmCC); 25 | * preserve :hover etc... in head styles 26 | 27 | == TODO: Future 28 | * respect @media rule (http://www.w3.org/TR/CSS21/media.html#at-media-rule) 29 | * complete shorthand properties support (border-width, font, background) 30 | * better quote escaping 31 | * UTF-8 and other charsets (test page: http://kianga.kcore.de/2004/09/21/utf8_test) 32 | * make warnings for border match border-left, etc... 33 | * correctly parse http://www.webstandards.org/files/acid2/test.html 34 | * Integrate CSS validator 35 | * Add body imposter div 36 | * Remove unused classes and IDs -------------------------------------------------------------------------------- /test/fixtures/test2.css: -------------------------------------------------------------------------------- 1 | /* 2 | Premailer (http://code.dunae.ca/premailer.web/) test stylesheet 3 | */ 4 | 5 | @import "test-import.css" screen; 6 | 7 | #container { 8 | margin: 0; 9 | width: 100%; 10 | font-size: 12px; 11 | } 12 | 13 | #frame { 14 | margin: 0 auto; 15 | width: 740px; 16 | } 17 | 18 | td { 19 | font-family: Georgia, Times, serif; 20 | line-height: 18px; 21 | } 22 | 23 | #head td { 24 | text-align: center; 25 | font-size: 11px; 26 | } 27 | 28 | 29 | #head td, #head a, 30 | #legal td, #legal a{ 31 | color: #fff; 32 | background: #1c2815 none; 33 | } 34 | 35 | #head a, #legal a { text-decoration: underline; } 36 | 37 | #content h1 { 38 | font-style: italic; 39 | font-size: 24px; 40 | } 41 | 42 | #content td { 43 | padding: 18px 0; 44 | color: #666; 45 | background: #deddc9 url("images/content_bg.jpg") repeat-y center top; 46 | border-color: #fff; 47 | border-style: solid; 48 | border-width: 10px; 49 | } 50 | 51 | .feature { 52 | border: 3px solid #fff; 53 | } 54 | 55 | img.right { 56 | margin: 0 36px 27px; 57 | float: right; 58 | } 59 | 60 | 61 | #content a { 62 | color: #900; 63 | background-color: transparent; 64 | font-weight: bold; 65 | } 66 | 67 | #content h1, 68 | #content td h2, 69 | #content td p { 70 | padding: 0 36px 9px; 71 | } 72 | 73 | ul.spacious li { 74 | margin: 18px; 75 | } 76 | 77 | .intro { 78 | font-size: 18px; 79 | } 80 | 81 | 82 | -------------------------------------------------------------------------------- /test/test_link_resolver.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/test_helper' 2 | 3 | 4 | class PremailerLinkResolverTests < Test::Unit::TestCase 5 | def test_resolving_urls_from_string 6 | ['test.html', '/test.html', './test.html', 7 | 'test/../test.html', 'test/../test/../test.html'].each do |q| 8 | assert_equal 'http://example.com/test.html', Premailer.resolve_link(q, 'http://example.com/'), q 9 | end 10 | 11 | assert_equal 'https://example.net:80/~basedir/test.html?var=1#anchor', Premailer.resolve_link('test/../test/../test.html?var=1#anchor', 'https://example.net:80/~basedir/') 12 | end 13 | 14 | def test_resolving_urls_from_uri 15 | base_uri = URI.parse('http://example.com/') 16 | ['test.html', '/test.html', './test.html', 17 | 'test/../test.html', 'test/../test/../test.html'].each do |q| 18 | assert_equal 'http://example.com/test.html', Premailer.resolve_link(q, base_uri), q 19 | end 20 | 21 | base_uri = URI.parse('https://example.net:80/~basedir/') 22 | assert_equal 'https://example.net:80/~basedir/test.html?var=1#anchor', Premailer.resolve_link('test/../test/../test.html?var=1#anchor', base_uri) 23 | end 24 | 25 | def test_resolving_local_paths 26 | base_dir = 'c:/root/' 27 | ['test.html', 'test/../test.html', './test.html', 28 | 'test/../test.html', 'test/../test/../test.html'].each do |q| 29 | assert_equal 'c:/root/test.html', Premailer.resolve_link(q, base_dir), q 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/fixtures/test3.css: -------------------------------------------------------------------------------- 1 | /* 2 | Premailer (http://code.dunae.ca/premailer.web/) test stylesheet 3 | */ 4 | 5 | @import "test3-import.css" screen; 6 | @import "test3-noimport.css" print; 7 | 8 | #container { 9 | margin: 0; 10 | width: 100%; 11 | font: 300 normal 13px "Georgia", Times, serif; 12 | font-size: 12px; 13 | } 14 | 15 | #frame { 16 | margin: 0 auto; 17 | width: 740px; 18 | } 19 | 20 | td { 21 | font-family: Georgia, Times, serif; 22 | line-height: 18px; 23 | } 24 | 25 | #head td { 26 | text-align: center; 27 | font-size: 11px; 28 | } 29 | 30 | 31 | #head td, #head a, 32 | #legal td, #legal a{ 33 | color: #fff; 34 | background: #1c2815 none; 35 | } 36 | 37 | #head a, #legal a { text-decoration: underline; } 38 | 39 | #content h1 { 40 | font-style: italic; 41 | font-size: 24px; 42 | } 43 | 44 | #content td { 45 | padding: 18px 0; 46 | color: #666; 47 | background: #deddc9 url("images/content_bg.jpg") repeat-y center top; 48 | border-color: #fff; 49 | border-style: solid; 50 | border-width: 10px; 51 | } 52 | 53 | .feature { 54 | border: 3px solid #fff; 55 | } 56 | 57 | img.right { 58 | margin: 0 36px 27px; 59 | float: right; 60 | } 61 | 62 | 63 | #content a { 64 | color: #900; 65 | background-color: transparent; 66 | font-weight: bold; 67 | } 68 | 69 | #content h1, 70 | #content td h2, 71 | #content td p { 72 | padding: 0 36px 9px; 73 | } 74 | 75 | ul.spacious li { 76 | margin: 18px; 77 | } 78 | 79 | .intro { 80 | font-size: 18px; 81 | } 82 | 83 | 84 | -------------------------------------------------------------------------------- /test/fixtures/test.css: -------------------------------------------------------------------------------- 1 | /* 2 | Premailer (http://code.dunae.ca/premailer.web/) test stylesheet 3 | */ 4 | 5 | body, #container { 6 | color: #fff; 7 | background: #1c2815 none; 8 | } 9 | 10 | #container { 11 | margin: 0; 12 | width: 100%; 13 | font-size: 12px; 14 | } 15 | 16 | #frame { 17 | margin: 0 auto; 18 | width: 740px; 19 | } 20 | 21 | td { 22 | font-family: Georgia, Times, serif; 23 | line-height: 18px; 24 | } 25 | 26 | #head td { 27 | text-align: center; 28 | font-size: 11px; 29 | } 30 | 31 | 32 | #head td, #head a, 33 | #legal td, #legal a{ 34 | color: #fff; 35 | background: #1c2815 none; 36 | } 37 | 38 | #head a, #legal a { text-decoration: underline; } 39 | 40 | #content h1 { 41 | font-style: italic; 42 | font-size: 24px; 43 | } 44 | 45 | #content td { 46 | padding: 18px 0; 47 | color: #666; 48 | background: #deddc9 url("images/content_bg.jpg") repeat-y center top; 49 | border-color: #fff; 50 | border-style: solid; 51 | border-width: 10px; 52 | } 53 | 54 | .feature { 55 | border: 3px solid #fff; 56 | } 57 | 58 | img.right { 59 | margin: 0 36px 27px; 60 | float: right; 61 | } 62 | 63 | 64 | #content a { 65 | color: #900; 66 | background-color: transparent; 67 | font-weight: bold; 68 | } 69 | 70 | #content h1, 71 | #content td h2, 72 | #content td p { 73 | padding: 0 36px 9px; 74 | } 75 | 76 | ul.spacious li { 77 | margin: 18px; 78 | } 79 | 80 | .intro { 81 | font-size: 18px; 82 | } 83 | 84 | #legal td { padding-top: 27px; } 85 | 86 | #legal p { margin: 0 36px; font-size: 11px; } 87 | 88 | #legal a { text-decoration: underline; } 89 | 90 | #legal .right { text-align: right; } 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | = Premailer License 2 | 3 | Copyright (c) 2007 Alex Dunae 4 | 5 | Premailer is copyrighted free software by Alex Dunae (http://dunae.ca/). 6 | You can redistribute it and/or modify it under the conditions below: 7 | 8 | 1. You may make and give away verbatim copies of the source form of the 9 | software without restriction, provided that you duplicate all of the 10 | original copyright notices and associated disclaimers. 11 | 12 | 2. You may modify your copy of the software in any way, provided that 13 | you do at least ONE of the following: 14 | 15 | a) place your modifications in the Public Domain or otherwise 16 | make them Freely Available, such as by posting said 17 | modifications to the internet or an equivalent medium, or by 18 | allowing the author to include your modifications in the software. 19 | 20 | b) use the modified software only within your corporation or 21 | organization. 22 | 23 | c) rename any non-standard executables so the names do not conflict 24 | with standard executables, which must also be provided. 25 | 26 | d) make other distribution arrangements with the author. 27 | 28 | 3. You may modify and include the part of the software into any other 29 | software (possibly commercial) as long as clear acknowledgement and 30 | a link back to the original software (http://code.dunae.ca/premailer.web/) 31 | is provided. 32 | 33 | 5. The scripts and library files supplied as input to or produced as 34 | output from the software do not automatically fall under the 35 | copyright of the software, but belong to whomever generated them, 36 | and may be sold commercially, and may be aggregated with this 37 | software. 38 | 39 | 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR 40 | IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED 41 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 42 | PURPOSE. -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | = Premailer README 2 | 3 | === What is this? 4 | 5 | For the best HTML e-mail delivery results, CSS should be inline. This is a 6 | huge pain and a simple newsletter becomes un-managable very quickly. This 7 | script is my solution. 8 | 9 | * All styles referenced by style and link[rel=stylesheet] 10 | tags are converted to inline styles 11 | * All style and link are removed (since they are now 12 | unnecessary) 13 | * Existing inline style attributes are preserved 14 | * CSS comments are stripped (including any hacks you might have) 15 | * All links (i.e. href, src and CSS url('')) 16 | are converted to absolute paths 17 | * CSS properties are checked against Campaign Monitor's guide to CSS support 18 | (http://www.campaignmonitor.com/blog/archives/2007/04/a_guide_to_css_support_in_emai_2.html) 19 | 20 | === Requirements, installation and use 21 | 22 | Premailer requires Ruby with the Hpricot 23 | (http://code.whytheluckystiff.net/hpricot/), text-reform 24 | (http://rubyforge.org/projects/text-format/) and CSS Parser 25 | (http://code.dunae.ca/css_parser/) gems installed. 26 | 27 | You can invoke Premailer via a rake task. 28 | 29 | rake parse url=http://example.com/ out=example.html 30 | 31 | To run Premailer off a web server (using link:../index.rhtml) you will need 32 | eRuby (http://www.modruby.net/en/index.rbx/eruby/download.html). 33 | 34 | === A few notes and caveats 35 | 36 | This script is designed for simple newsletters files--complex files can 37 | get ugly very quickly. 38 | 39 | * selectors are ignored--they are just too messy. 40 | 41 | Premailer outputs HTML in UTF-8. 42 | 43 | * CSS media selectors are ignored--all CSS is processed 44 | 45 | Take a look at the HTML test file (link:../test/test.html) and CSS test file 46 | (link:../test/test.css) to see what sort of code is intended. 47 | 48 | === Credits and code 49 | 50 | Premailer is written in Ruby. The code is and web interface can be found at 51 | http://code.dunae.ca/premailer.web/ 52 | 53 | Written by Alex Dunae (dunae.ca, e-mail 'code' at the same domain), 2007. 54 | -------------------------------------------------------------------------------- /lib/html_to_plain_text.rb: -------------------------------------------------------------------------------- 1 | require 'text/reform' 2 | require 'htmlentities' 3 | 4 | # Support functions for Premailer 5 | module HtmlToPlainText 6 | 7 | # Returns the text in UTF-8 format with all HTML tags removed 8 | # 9 | # TODO: 10 | # - add support for DL, OL 11 | def convert_to_text(html, line_length, from_charset = 'UTF-8') 12 | r = Text::Reform.new(:trim => true, 13 | :squeeze => false, 14 | :break => Text::Reform.break_wrap) 15 | 16 | txt = html 17 | 18 | he = HTMLEntities.new # decode HTML entities 19 | 20 | txt = he.decode(txt) 21 | 22 | txt.gsub!(/]*>(.*)<\/h[0-9]+>/i) do |s| # handle headings 23 | hlevel = $1.to_i 24 | htext = $2.gsub(/<\/?[^>]*>/i, '') # remove tags inside headings 25 | hlength = (htext.length > line_length ? 26 | line_length : 27 | htext.length) 28 | 29 | case hlevel 30 | when 1 # H1 31 | ('*' * hlength) + "\n" + htext + "\n" + ('*' * hlength) + "\n" 32 | when 2 # H2 33 | ('-' * hlength) + "\n" + htext + "\n" + ('-' * hlength) + "\n" 34 | else # H3-H6 are styled the same 35 | htext + "\n" + ('-' * htext.length) + "\n" 36 | end 37 | end 38 | 39 | txt.gsub!(/]*>(.*)<\/a>/i) do |s| # links 40 | $2 + ' [' + $1 + ']' 41 | end 42 | 43 | txt.gsub!(/(]*>|
  • )/i, ' * ') # unordered LIsts 44 | txt.gsub!(/<\/p>/i, "\n\n") # paragraphs 45 | 46 | txt.gsub!(/<\/?[^>]*>/, '') # strip remaining tags 47 | txt.gsub!(/\A[\s]+|[\s]+\Z|^[ \t]+/m, '') # strip extra spaces 48 | txt.gsub!(/[\n]{3,}/m, "\n\n") # tighten line breaks 49 | 50 | txt = r.format(('[' * line_length), txt) # wrap text 51 | txt.gsub!(/^[\*][\s]/m, ' * ') # add spaces back to lists 52 | txt 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/test_premailer_download.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/test_helper' 2 | 3 | # Test cases for the CssParser's downloading functions. 4 | class PremailerDownloadTest < Test::Unit::TestCase 5 | include WEBrick 6 | 7 | def setup 8 | @uri_base = 'http://localhost:12000' 9 | 10 | www_root = File.dirname(__FILE__) + '/fixtures/' 11 | 12 | @server_thread = Thread.new do 13 | s = WEBrick::HTTPServer.new(:Port => 12000, :DocumentRoot => www_root, :Logger => Log.new(nil, BasicLog::ERROR), :AccessLog => []) 14 | @port = s.config[:Port] 15 | begin 16 | s.start 17 | ensure 18 | s.shutdown 19 | end 20 | end 21 | 22 | sleep 1 # ensure the server has time to load 23 | end 24 | 25 | def teardown 26 | @server_thread.kill 27 | @server_thread.join(5) 28 | @server_thread = nil 29 | end 30 | 31 | def test_loading_a_remote_file 32 | @cp.load_uri!("#{@uri_base}/simple.css") 33 | assert_equal 'margin: 0px;', @cp.find_by_selector('p').join(' ') 34 | end 35 | 36 | def test_following_at_import_rules 37 | @cp.load_uri!("#{@uri_base}/import1.css") 38 | 39 | # from '/import1.css' 40 | assert_equal 'color: lime;', @cp.find_by_selector('div').join(' ') 41 | 42 | # from '/subdir/import2.css' 43 | assert_equal 'text-decoration: none;', @cp.find_by_selector('a').join(' ') 44 | 45 | # from '/subdir/../simple.css' 46 | assert_equal 'margin: 0px;', @cp.find_by_selector('p').join(' ') 47 | end 48 | 49 | def test_importing_with_media_types 50 | @cp.load_uri!("#{@uri_base}/import-with-media-types.css") 51 | 52 | # from simple.css with :screen media type 53 | assert_equal 'margin: 0px;', @cp.find_by_selector('p', :screen).join(' ') 54 | assert_equal '', @cp.find_by_selector('p', :tty).join(' ') 55 | end 56 | 57 | def test_throwing_circular_reference_exception 58 | assert_raise CircularReferenceError do 59 | @cp.load_uri!("#{@uri_base}/import-circular-reference.css") 60 | end 61 | end 62 | 63 | def test_toggling_not_found_exceptions 64 | cp_with_exceptions = Parser.new(:io_exceptions => true) 65 | 66 | assert_raise RemoteFileError do 67 | cp_with_exceptions.load_uri!("#{@uri_base}/no-exist.xyz") 68 | end 69 | 70 | cp_without_exceptions = Parser.new(:io_exceptions => false) 71 | 72 | assert_nothing_raised RemoteFileError do 73 | cp_without_exceptions.load_uri!("#{@uri_base}/no-exist.xyz") 74 | end 75 | end 76 | 77 | end 78 | -------------------------------------------------------------------------------- /bin/premailer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'optparse' 3 | require 'optparse/time' 4 | require 'ostruct' 5 | require 'fileutils' 6 | require File.dirname(__FILE__) + '/../lib/premailer' 7 | 8 | def exit_with_error(opts, msg = '') 9 | puts msg + "\n\n" unless msg.empty? 10 | puts opts 11 | exit 12 | end 13 | 14 | 15 | options = OpenStruct.new 16 | options.plaintext = false 17 | options.outfile = '' 18 | options.querystring = '' 19 | options.warnings = false 20 | 21 | opts = OptionParser.new do |opts| 22 | opts.banner = "Usage: premailer.rb inputfile outputfile [options]\n Examples:\n" 23 | opts.banner << " premailer myfile.html myfile -b http://example.com/\n" 24 | opts.banner << " premailer http://example.com/myfile.html out/myfile -w -q src=email" 25 | opts.separator "" 26 | opts.separator "Specific options:" 27 | 28 | opts.on("-t", "--plaintext", "Create plain-text version") do |t| 29 | options.plaintext = t 30 | end 31 | 32 | opts.on("-q", "--querystring [STRING]", "Query string to append to links") do |qs| 33 | options.querystring = qs || '' 34 | options.querystring.gsub!(/^\?/, '') 35 | end 36 | 37 | opts.on("-b", "--baseurl [STRING]", "Base URL; only applies to local files") do |bs| 38 | options.baseurl = bs 39 | end 40 | 41 | opts.on("-w", "--warnings", "Generate CSS/HTML warnings") do |w| 42 | options.warnings = w 43 | end 44 | 45 | opts.on_tail("-h", "--help", "Show this message") do 46 | puts opts 47 | exit 48 | end 49 | end 50 | 51 | opts.parse!(ARGV) 52 | 53 | src_url = ARGV[0] 54 | exit_with_error(opts, "You must specify a file to parse") if src_url.nil? 55 | 56 | outfile = ARGV[1] 57 | exit_with_error(opts, "You must specify an output file") if outfile.nil? 58 | 59 | outdir = File.dirname(outfile) 60 | 61 | unless File.exists?(outdir) and File.directory?(outdir) 62 | FileUtils.mkdir_p outdir 63 | end 64 | 65 | 66 | puts "Parsing #{src_url}" 67 | puts " - plaintext: #{options.plaintext}" 68 | puts " - querystring: #{options.querystring}" 69 | 70 | 71 | unless src_url =~ /^(http|https|ftp)\:\/\//i 72 | local_file = File.expand_path(File.dirname(__FILE__) + '/' + src_url) 73 | raise "Could not find #{local_file}" unless File.exists?(local_file) 74 | end 75 | 76 | 77 | premailer = Premailer.new(src_url, :warn_level => Premailer::Warnings::SAFE, :link_query_string => options.querystring) 78 | 79 | 80 | 81 | 82 | fout = File.open("#{outfile}.html", "w") 83 | fout.puts premailer.to_inline_css 84 | fout.close 85 | 86 | puts "Succesfully parsed '#{src_url}' into '#{outfile}.html'" 87 | 88 | if options.plaintext 89 | fout = File.open("#{outfile}.txt", "w") 90 | fout.puts premailer.to_plain_text 91 | fout.close 92 | puts "Succesfully parsed '#{src_url}' into '#{outfile}.txt'" 93 | end 94 | 95 | 96 | if options.warnings 97 | fwarn = File.open("#{outfile}.warnings.txt", "w") 98 | premailer.warnings.each do |w| 99 | fwarn.puts "#{w[:message]} (#{w[:level]})\n May not render properly in #{w[:clients]}\n\n" 100 | end 101 | fwarn.close 102 | puts "Saved #{premailer.warnings.length.to_s} CSS/HTML warnings to #{outfile}.warnings.txt" 103 | end -------------------------------------------------------------------------------- /test/fixtures/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Premailer Test Page 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
    13 |
    14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 47 | 48 |
    25 | 26 | 27 |

    Premailer Test Page

    28 | 29 | Inset image 30 | 31 |

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    32 | 33 |

    Sed quis justo ut velit mattis adipiscing. Sed in mauris et libero dapibus varius. Quisque quis neque sed neque tincidunt scelerisque. Praesent vitae velit sit amet libero pharetra rhoncus. Phasellus laoreet. Sed nibh. Integer venenatis odio vitae neque. Quisque et mi ac libero blandit interdum. Suspendisse vitae massa. Suspendisse purus ligula, egestas in, ultrices non, fermentum at, lorem.

    34 | 35 |

    Links

    36 | 37 | 43 | 44 |

    Vestibulum tristique adipiscing nisi. Suspendisse bibendum pretium justo. Vestibulum nec ante. Nulla mollis molestie nibh. Curabitur facilisis neque a risus. Curabitur aliquam gravida nisl. Sed consectetuer. Nullam interdum faucibus quam. Suspendisse pulvinar orci nec metus. Vivamus nonummy. Nam dignissim arcu a metus. Mauris leo. Nulla facilisi. Ut varius. Duis ultricies tristique magna. Vestibulum in lorem vitae diam pharetra vehicula. Suspendisse condimentum. Curabitur dictum magna eget tellus.

    45 | 46 |
    49 | 50 | 51 | 52 | 56 | 60 | 61 | 62 | 63 |
    64 |
    65 | 66 | -------------------------------------------------------------------------------- /test/fixtures/test-with-folding.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Premailer Test Page 5 | 6 | 7 | 8 | 9 | 10 | 21 | 22 | 23 |
    24 |
    25 | 26 | 27 | 30 | 31 | 32 | 33 | 34 | 35 | 58 | 59 |
    36 | 37 | 38 |

    Premailer Test Page

    39 | 40 | Inset image 41 | 42 |

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    43 | 44 |

    Sed quis justo ut velit mattis adipiscing. Sed in mauris et libero dapibus varius. Quisque quis neque sed neque tincidunt scelerisque. Praesent vitae velit sit amet libero pharetra rhoncus. Phasellus laoreet. Sed nibh. Integer venenatis odio vitae neque. Quisque et mi ac libero blandit interdum. Suspendisse vitae massa. Suspendisse purus ligula, egestas in, ultrices non, fermentum at, lorem.

    45 | 46 |

    Links

    47 | 48 | 54 | 55 |

    Vestibulum tristique adipiscing nisi. Suspendisse bibendum pretium justo. Vestibulum nec ante. Nulla mollis molestie nibh. Curabitur facilisis neque a risus. Curabitur aliquam gravida nisl. Sed consectetuer. Nullam interdum faucibus quam. Suspendisse pulvinar orci nec metus. Vivamus nonummy. Nam dignissim arcu a metus. Mauris leo. Nulla facilisi. Ut varius. Duis ultricies tristique magna. Vestibulum in lorem vitae diam pharetra vehicula. Suspendisse condimentum. Curabitur dictum magna eget tellus.

    56 | 57 |
    60 | 61 | 62 | 63 | 67 | 71 | 72 | 73 | 74 |
    75 |
    76 | 77 | -------------------------------------------------------------------------------- /test/fixtures/test2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Premailer Test Page 5 | 6 | 7 | 8 | 9 | 10 | 19 | 20 | 21 |
    22 |
    23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | 56 | 57 |
    34 | 35 | 36 |

    Premailer Test Page

    37 | 38 | Inset image 39 | 40 |

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    41 | 42 |

    Sed quis justo ut velit mattis adipiscing. Sed in mauris et libero dapibus varius. Quisque quis neque sed neque tincidunt scelerisque. Praesent vitae velit sit amet libero pharetra rhoncus. Phasellus laoreet. Sed nibh. Integer venenatis odio vitae neque. Quisque et mi ac libero blandit interdum. Suspendisse vitae massa. Suspendisse purus ligula, egestas in, ultrices non, fermentum at, lorem.

    43 | 44 |

    Links

    45 | 46 | 52 | 53 |

    Vestibulum tristique adipiscing nisi. Suspendisse bibendum pretium justo. Vestibulum nec ante. Nulla mollis molestie nibh. Curabitur facilisis neque a risus. Curabitur aliquam gravida nisl. Sed consectetuer. Nullam interdum faucibus quam. Suspendisse pulvinar orci nec metus. Vivamus nonummy. Nam dignissim arcu a metus. Mauris leo. Nulla facilisi. Ut varius. Duis ultricies tristique magna. Vestibulum in lorem vitae diam pharetra vehicula. Suspendisse condimentum. Curabitur dictum magna eget tellus.

    54 | 55 |
    58 | 59 | 60 | 61 | 65 | 69 | 70 | 71 | 72 |
    73 |
    74 | 75 | -------------------------------------------------------------------------------- /test/fixtures/test3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Premailer Test Page 5 | 6 | 7 | 8 | 9 | 10 | 23 | 24 | 25 |
    26 |
    27 | 28 | 29 | 32 | 33 | 34 | 35 | 36 | 37 | 66 | 67 |
    38 | 39 | 40 |

    Premailer Test Page

    41 | 42 | Inset image 43 | 44 |

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    45 | 46 |

    Sed quis justo ut velit mattis adipiscing. Sed in mauris et libero dapibus varius. Quisque quis neque sed neque tincidunt scelerisque. Praesent vitae velit sit amet libero pharetra rhoncus. Phasellus laoreet. Sed nibh. Integer venenatis odio vitae neque. Quisque et mi ac libero blandit interdum. Suspendisse vitae massa. Suspendisse purus ligula, egestas in, ultrices non, fermentum at, lorem.

    47 | 48 |

    Links

    49 | 50 | 62 | 63 |

    Vestibulum tristique adipiscing nisi. Suspendisse bibendum pretium justo. Vestibulum nec ante. Nulla mollis molestie nibh. Curabitur facilisis neque a risus. Curabitur aliquam gravida nisl. Sed consectetuer. Nullam interdum faucibus quam. Suspendisse pulvinar orci nec metus. Vivamus nonummy. Nam dignissim arcu a metus. Mauris leo. Nulla facilisi. Ut varius. Duis ultricies tristique magna. Vestibulum in lorem vitae diam pharetra vehicula. Suspendisse condimentum. Curabitur dictum magna eget tellus.

    64 | 65 |
    68 | 69 | 70 | 71 | 75 | 79 | 80 | 81 | 82 |
    83 |
    84 | 85 | -------------------------------------------------------------------------------- /test/fixtures/test3-out.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Premailer Test Page 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 20 | 21 | 22 |
    23 |
    24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | 34 | 63 | 64 |
    35 | 36 | 37 |

    Premailer Test Page

    38 | 39 | Inset image 40 | 41 |

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    42 | 43 |

    Sed quis justo ut velit mattis adipiscing. Sed in mauris et libero dapibus varius. Quisque quis neque sed neque tincidunt scelerisque. Praesent vitae velit sit amet libero pharetra rhoncus. Phasellus laoreet. Sed nibh. Integer venenatis odio vitae neque. Quisque et mi ac libero blandit interdum. Suspendisse vitae massa. Suspendisse purus ligula, egestas in, ultrices non, fermentum at, lorem.

    44 | 45 |

    Links

    46 | 47 | 59 | 60 |

    Vestibulum tristique adipiscing nisi. Suspendisse bibendum pretium justo. Vestibulum nec ante. Nulla mollis molestie nibh. Curabitur facilisis neque a risus. Curabitur aliquam gravida nisl. Sed consectetuer. Nullam interdum faucibus quam. Suspendisse pulvinar orci nec metus. Vivamus nonummy. Nam dignissim arcu a metus. Mauris leo. Nulla facilisi. Ut varius. Duis ultricies tristique magna. Vestibulum in lorem vitae diam pharetra vehicula. Suspendisse condimentum. Curabitur dictum magna eget tellus.

    61 | 62 |
    65 | 66 | 67 | 68 | 72 | 76 | 77 | 78 | 79 |
    80 |
    81 | 82 | 83 | -------------------------------------------------------------------------------- /misc/client_support.yaml: -------------------------------------------------------------------------------- 1 | # Capabilities of e-mail clients 2 | # 3 | # Sources 4 | # * http://www.campaignmonitor.com/blog/archives/2007/04/a_guide_to_css_support_in_emai_2.html 5 | # * http://www.campaignmonitor.com/blog/archives/2007/11/do_image_maps_work_in_html_ema.html 6 | # * http://www.campaignmonitor.com/blog/archives/2007/11/how_forms_perform_in_html_emai.html 7 | # * http://www.xavierfrenette.com/articles/css-support-in-webmail/ 8 | # * http://www.email-standards.org/ 9 | # Updated 2007-11-28 10 | # 11 | # Support: 1 = SAFE, 2 = POOR, 3 = RISKY 12 | elements: 13 | map: 14 | support: 2 15 | unsupported_in: [Gmail] 16 | area: 17 | support: 2 18 | unsupported_in: [Gmail] 19 | form: 20 | support: 3 21 | unsupported_in: [dotMac, Old Yahoo, AOL, Live Mail, Outlook 07, Outlook 03] 22 | link: 23 | support: 2 24 | unsupported_in: [Gmail, Hotmail, Old Yahoo] 25 | attributes: 26 | ismap: 27 | support: 2 28 | unsupported_in: [Gmail] 29 | css_properties: 30 | background-color: 31 | support: 2 32 | unsupported_in: [Notes 6, Eudora, dotMac] 33 | background-image: 34 | support: 3 35 | unsupported_in: [Outlook 07, Gmail, Live Mail, Notes 6, Eudora, dotMac] 36 | background-image-position: 37 | support: 3 38 | unsupported_in: [Outlook 07, Gmail, Live Mail, Notes 6, Eudora, dotMac] 39 | background-repeat: 40 | support: 3 41 | unsupported_in: [Outlook 07, Gmail, Live Mail, Notes 6, Eudora] 42 | background-position: 43 | support: 3 44 | unsupported_in: [Old Yahoo, Outlook 07, Gmail, Live Mail, Hotmail, Notes 6, Eudora] 45 | border: &border_shorthand 46 | support: 2 47 | unsupported_in: [Notes 6, Eudora] 48 | border-bottom: *border_shorthand 49 | border-left: *border_shorthand 50 | border-right: *border_shorthand 51 | border-top: *border_shorthand 52 | border-collapse: 53 | support: 3 54 | unsupported_in: [Entourage, Notes 6, Eudora] 55 | border-spacing: 56 | support: 3 57 | unsupported_in: [Outlook 03, Outlook 07, Live Mail, Entourage, Hotmail, Notes 6, Eudora] 58 | bottom: 59 | support: 3 60 | unsupported_in: [New Yahoo, AOL, Outlook 07, Gmail, Live Mail, Notes 6, Eudora] 61 | caption-side: 62 | support: 3 63 | unsupported_in: [Outlook 03, New Yahoo, AOL, Outlook 07, Mac Mail, Entourage, Hotmail, Notes 6, Eudora] 64 | clear: 65 | support: 3 66 | unsupported_in: [Outlook 07, Notes 6, Eudora, dotMac] 67 | clip: 68 | support: 3 69 | unsupported_in: [New Yahoo, Outlook 07, Live Mail, Notes 6, Eudora] 70 | color: 71 | support: 1 72 | unsupported_in: [Eudora, dotMac] 73 | cursor: 74 | support: 3 75 | unsupported_in: [Outlook 07, Gmail, Entourage, Notes 6, Eudora] 76 | direction: 77 | support: 3 78 | unsupported_in: [Outlook 07, Entourage, Eudora] 79 | display: 80 | support: 2 81 | unsupported_in: [Outlook 07, Eudora] 82 | empty-cells: 83 | support: 3 84 | unsupported_in: [Outlook 03, AOL, Outlook 07, Entourage, Hotmail, Notes 6, Eudora] 85 | font-size: 86 | support: 1 87 | unsupported_in: [Eudora, dotMac] 88 | font-style: 89 | support: 1 90 | unsupported_in: [Eudora, dotMac] 91 | font-weight: 92 | support: 1 93 | unsupported_in: [Eudora, dotMac] 94 | font-family: 95 | support: 2 96 | unsupported_in: [Gmail, Eudora, dotMac] 97 | font-variant: 98 | support: 2 99 | unsupported_in: [Notes 6, Eudora, dotMac] 100 | float: 101 | support: 3 102 | unsupported_in: [Outlook 07, Gmail, Eudora, dotMac] 103 | height: 104 | support: 3 105 | unsupported_in: [Outlook 07, Gmail, Notes 6, Eudora, dotMac] 106 | left: 107 | support: 3 108 | unsupported_in: [New Yahoo, Outlook 07, Gmail, Live Mail, Notes 6, Eudora] 109 | letter-spacing: 110 | support: 2 111 | unsupported_in: [Notes 6, Eudora] 112 | line-height: 113 | support: 2 114 | unsupported_in: [Notes 6, Eudora, dotMac] 115 | list-style-image: 116 | support: 3 117 | unsupported_in: [Outlook 07, Gmail, Live Mail, Notes 6, Eudora, dotMac] 118 | list-style-position: 119 | support: 3 120 | unsupported_in: [Old Yahoo, Outlook 07, Hotmail, Notes 6, Eudora] 121 | list-style-type: 122 | support: 3 123 | unsupported_in: [Outlook 07, Live Mail, Hotmail, Eudora] 124 | margin: &margin_shorthand 125 | support: 3 126 | unsupported_in: [AOL, Live Mail, Hotmail, Notes 6, Eudora, dotMac] 127 | margin-bottom: *margin_shorthand 128 | margin-left: *margin_shorthand 129 | margin-right: *margin_shorthand 130 | margin-top: *margin_shorthand 131 | opacity: 132 | support: 3 133 | unsupported_in: [Outlook 03, New Yahoo, Outlook 07, Gmail, Live Mail, Entourage, Hotmail, Notes 6, Eudora] 134 | overflow: 135 | support: 3 136 | unsupported_in: [Outlook 07, Entourage, Notes 6, Eudora] 137 | padding: &padding_shorthand 138 | support: 2 139 | unsupported_in: [Notes 6, Eudora, dotMac] 140 | padding-bottom: *padding_shorthand 141 | padding-left: *padding_shorthand 142 | padding-right: *padding_shorthand 143 | padding-top: *padding_shorthand 144 | position: 145 | support: 3 146 | unsupported_in: [New Yahoo, Old Yahoo, Outlook 07, Gmail, Live Mail, Hotmail, Notes 6, Eudora] 147 | table-layout: 148 | support: 2 149 | unsupported_in: [Notes 6, Eudora] 150 | text-align: 151 | support: 1 152 | unsupported_in: [Eudora] 153 | text-decoration: 154 | support: 1 155 | unsupported_in: [Eudora] 156 | text-indent: 157 | support: 2 158 | unsupported_in: [Notes 6, Eudora] 159 | text-transform: 160 | support: 2 161 | unsupported_in: [Notes 6, Eudora] 162 | top: 163 | support: 3 164 | unsupported_in: [New Yahoo, Outlook 07, Gmail, Live Mail, Notes 6, Eudora] 165 | right: 166 | support: 3 167 | unsupported_in: [New Yahoo, Outlook 07, Gmail, Live Mail, Notes 6, Eudora] 168 | vertical-align: 169 | support: 3 170 | unsupported_in: [Outlook 07, Notes 6, Eudora] 171 | visibility: 172 | support: 3 173 | unsupported_in: [Outlook 07, Gmail, Notes 6, Eudora] 174 | white-space: 175 | support: 3 176 | unsupported_in: [Outlook 03, AOL, Notes 6, Eudora] 177 | width: 178 | support: 3 179 | unsupported_in: [Outlook 07, Notes 6, Eudoram, dotMac] 180 | word-spacing: 181 | support: 3 182 | unsupported_in: [Outlook 07, Notes 6, Eudora] 183 | z-index: 184 | support: 3 185 | unsupported_in: [New Yahoo, Gmail, Live Mail, Notes 6, Eudora] 186 | -------------------------------------------------------------------------------- /lib/premailer.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # 3 | # Premailer by Alex Dunae (dunae.ca, e-mail 'code' at the same domain), 2008 4 | # Version 1.5.0 5 | 6 | ENV["GEM_PATH"] = "/home/alexdunae/.gems:/usr/lib/ruby/gems/1.8" 7 | 8 | $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__), '')) 9 | 10 | require 'rubygems' 11 | require 'yaml' 12 | require 'open-uri' 13 | require 'hpricot' 14 | require 'css_parser' 15 | 16 | require 'html_to_plain_text' 17 | 18 | # Premailer processes HTML and CSS to improve e-mail deliverability. 19 | # 20 | # Premailer's main function is to render all CSS as inline style attributes using 21 | # the CssParser. It can also convert relative links to absolute links and check the 'safety' of 22 | # CSS properties against a CSS support chart. 23 | # 24 | # = Example 25 | # 26 | # premailer = Premailer.new(html_file, :warn_level => Premailer::Warnings::SAFE) 27 | # premailer.parse! 28 | # puts premailer.warnings.length.to_s + ' warnings found' 29 | class Premailer 30 | include HtmlToPlainText 31 | include CssParser 32 | 33 | CLIENT_SUPPORT_FILE = File.dirname(__FILE__) + '/../misc/client_support.yaml' 34 | 35 | RE_UNMERGABLE_SELECTORS = /(\:(visited|active|hover|focus|after|before|selection|target|first\-(line|letter))|^\@)/i 36 | 37 | # should also exclude :first-letter, etc... 38 | 39 | # URI of the HTML file used 40 | attr_reader :html_file 41 | 42 | module Warnings 43 | NONE = 0 44 | SAFE = 1 45 | POOR = 2 46 | RISKY = 3 47 | end 48 | include Warnings 49 | 50 | WARN_LABEL = %w(NONE SAFE POOR RISKY) 51 | 52 | # Create a new Premailer object. 53 | # 54 | # +uri+ is the URL of the HTML file to process. Should be a string. 55 | # 56 | # ==== Options 57 | # [+line_length+] Line length used by to_plain_text. Boolean, default is 65. 58 | # [+warn_level+] What level of CSS compatibility warnings to show (see Warnings). 59 | # [+link_query_string+] A string to append to every link. 60 | def initialize(uri, options = {}) 61 | @options = {:warn_level => Warnings::SAFE, :line_length => 65, :link_query_string => nil, :base_url => nil}.merge(options) 62 | @html_file = uri 63 | 64 | 65 | @is_local_file = true 66 | if uri =~ /^(http|https|ftp)\:\/\//i 67 | @is_local_file = false 68 | end 69 | 70 | 71 | @css_warnings = [] 72 | 73 | @css_parser = CssParser::Parser.new({:absolute_paths => true, 74 | :import => true, 75 | :io_exceptions => false 76 | }) 77 | 78 | @doc, @html_charset = load_html(@html_file) 79 | 80 | if @is_local_file and @options[:base_url] 81 | @doc = convert_inline_links(@doc, @options[:base_url]) 82 | elsif not @is_local_file 83 | @doc = convert_inline_links(@doc, @html_file) 84 | end 85 | load_css_from_html! 86 | end 87 | 88 | # Array containing a hash of CSS warnings. 89 | def warnings 90 | return [] if @options[:warn_level] == Warnings::NONE 91 | @css_warnings = check_client_support if @css_warnings.empty? 92 | @css_warnings 93 | end 94 | 95 | # Returns the original HTML as a string. 96 | def to_s 97 | @doc.to_html 98 | end 99 | 100 | # Returns the document with all HTML tags removed. 101 | def to_plain_text 102 | html_src = '' 103 | begin 104 | html_src = @doc.search("body").innerHTML 105 | rescue 106 | html_src = @doc.to_html 107 | end 108 | convert_to_text(html_src, @options[:line_length], @html_charset) 109 | end 110 | 111 | # Merge CSS into the HTML document. 112 | # 113 | # Returns a string. 114 | def to_inline_css 115 | doc = @doc 116 | unmergable_rules = CssParser::Parser.new 117 | 118 | # Give all styles already in style attributes a specificity of 1000 119 | # per http://www.w3.org/TR/CSS21/cascade.html#specificity 120 | doc.search("*[@style]").each do |el| 121 | el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]' 122 | end 123 | 124 | # Iterate through the rules and merge them into the HTML 125 | @css_parser.each_selector(:all) do |selector, declaration, specificity| 126 | # Save un-mergable rules separately 127 | selector.gsub!(/:link([\s]|$)+/i, '') 128 | 129 | # Convert element names to lower case 130 | selector.gsub!(/([\s]|^)([\w]+)/) {|m| $1.to_s + $2.to_s.downcase } 131 | 132 | if selector =~ RE_UNMERGABLE_SELECTORS 133 | unmergable_rules.add_rule_set!(RuleSet.new(selector, declaration)) 134 | else 135 | 136 | doc.search(selector) do |el| 137 | if el.elem? 138 | # Add a style attribute or append to the existing one 139 | block = "[SPEC=#{specificity}[#{declaration}]]" 140 | el['style'] = (el.attributes['style'] ||= '') + ' ' + block 141 | end 142 | end 143 | end 144 | end 145 | 146 | # Read \n" 225 | doc.search("head").append(style_tag) 226 | end 227 | doc 228 | end 229 | 230 | # Convert relative links to absolute links. 231 | # 232 | # Processes href src and background attributes 233 | # as well as CSS url() declarations found in inline style attributes. 234 | # 235 | # doc is an Hpricot document and base_uri is either a string or a URI. 236 | # 237 | # Returns an Hpricot document. 238 | def convert_inline_links(doc, base_uri) 239 | base_uri = URI.parse(base_uri) unless base_uri.kind_of?(URI) 240 | 241 | ['href', 'src', 'background'].each do |attribute| 242 | 243 | tags = doc.search("*[@#{attribute}]") 244 | append_qs = @options[:link_query_string] ||= '' 245 | unless tags.empty? 246 | tags.each do |tag| 247 | unless tag.attributes[attribute] =~ /^(\{|\[|<|\#)/i 248 | if tag.attributes[attribute] =~ /^http/i 249 | begin 250 | merged = URI.parse(tag.attributes[attribute]) 251 | rescue 252 | next 253 | end 254 | else 255 | begin 256 | merged = Premailer.resolve_link(tag.attributes[attribute].to_s, base_uri) 257 | rescue 258 | begin 259 | merged = Premailer.resolve_link(URI.escape(tag.attributes[attribute].to_s), base_uri) 260 | # merged = base_uri.merge(URI.escape(tag.attributes[attribute].to_s)) 261 | rescue; end 262 | end 263 | end # end of relative urls only 264 | 265 | if tag.name =~ /^a$/i and not append_qs.empty? 266 | if merged.query 267 | merged.query = merged.query + '&' + append_qs 268 | else 269 | merged.query = append_qs 270 | end 271 | end 272 | tag[attribute] = merged.to_s 273 | end # end of skipping special chars 274 | 275 | 276 | end # end of each tag 277 | end # end of empty 278 | end # end of attrs 279 | 280 | doc.search("*[@style]").each do |el| 281 | el['style'] = CssParser.convert_uris(el.attributes['style'].to_s, base_uri) 282 | end 283 | 284 | doc.search("style").each do |el| 285 | el.inner_html = CssParser.convert_uris(el.inner_html.to_s, base_uri) 286 | end 287 | 288 | doc 289 | end 290 | 291 | def self.escape_string(str) 292 | str.gsub(/"/, "'") 293 | end 294 | 295 | def self.resolve_link(path, base_path) 296 | if base_path.kind_of?(URI) 297 | base_path.merge!(path) 298 | return Premailer.canonicalize(base_path) 299 | elsif base_path.kind_of?(String) and base_path =~ /^(http[s]?|ftp):\/\//i 300 | base_uri = URI.parse(base_path) 301 | base_uri.merge!(path) 302 | return Premailer.canonicalize(base_uri) 303 | else 304 | 305 | return File.expand_path(path, File.dirname(base_path)) 306 | end 307 | end 308 | 309 | # from http://www.ruby-forum.com/topic/140101 310 | def self.canonicalize(uri) 311 | u = uri.kind_of?(URI) ? uri : URI.parse(uri.to_s) 312 | u.normalize! 313 | newpath = u.path 314 | while newpath.gsub!(%r{([^/]+)/\.\./?}) { |match| 315 | $1 == '..' ? match : '' 316 | } do end 317 | newpath = newpath.gsub(%r{/\./}, '/').sub(%r{/\.\z}, '/') 318 | u.path = newpath 319 | u.to_s 320 | end 321 | 322 | 323 | 324 | def add_body_imposter(doc) 325 | newdoc = doc 326 | if body_tag = newdoc.at("body") and body_tag.attributes["style"] 327 | body_html = body_tag.inner_html 328 | body_tag.inner_html = "\n
    \n#{body_html}\n
    \n" 329 | if body_tag.attributes["style"] 330 | newdoc.at("#premailer_body_wrapper")["style"] = body_tag.attributes["style"].to_s 331 | newdoc.at("body")["style"] = "margin: 0; padding: 0;" 332 | end 333 | 334 | end 335 | return newdoc 336 | rescue 337 | return doc 338 | end 339 | 340 | 341 | # Check CLIENT_SUPPORT_FILE for any CSS warnings 342 | def check_client_support 343 | @client_support = @client_support ||= YAML::load(File.open(CLIENT_SUPPORT_FILE)) 344 | 345 | warnings = [] 346 | properties = [] 347 | 348 | # Get a list off CSS properties 349 | @doc.search("*[@style]").each do |el| 350 | style_url = el.attributes['style'].gsub(/([\w\-]+)[\s]*\:/i) do |s| 351 | properties.push($1) 352 | end 353 | end 354 | 355 | properties.uniq! 356 | 357 | property_support = @client_support['css_properties'] 358 | properties.each do |prop| 359 | if property_support.include?(prop) and property_support[prop]['support'] >= @options[:warn_level] 360 | warnings.push({:message => "#{prop} CSS property", 361 | :level => WARN_LABEL[property_support[prop]['support']], 362 | :clients => property_support[prop]['unsupported_in'].join(', ')}) 363 | end 364 | end 365 | 366 | @client_support['attributes'].each do |attribute, data| 367 | next unless data['support'] >= @options[:warn_level] 368 | if @doc.search("*[@#{attribute}]").length > 0 369 | warnings.push({:message => "#{attribute} HTML attribute", 370 | :level => WARN_LABEL[property_support[prop]['support']], 371 | :clients => property_support[prop]['unsupported_in'].join(', ')}) 372 | end 373 | end 374 | 375 | @client_support['elements'].each do |element, data| 376 | next unless data['support'] >= @options[:warn_level] 377 | if @doc.search("element").length > 0 378 | warnings.push({:message => "#{element} HTML element", 379 | :level => WARN_LABEL[property_support[prop]['support']], 380 | :clients => property_support[prop]['unsupported_in'].join(', ')}) 381 | end 382 | end 383 | 384 | 385 | 386 | 387 | 388 | return warnings 389 | end 390 | end 391 | 392 | 393 | --------------------------------------------------------------------------------