premailer-plus 0.1.3
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +8 -0
- data/LICENSE +42 -0
- data/README.textile +43 -0
- data/Rakefile +62 -0
- data/VERSION.yml +4 -0
- data/bin/premailer_plus +107 -0
- data/lib/html_to_plain_text.rb +61 -0
- data/lib/premailer.rb +401 -0
- data/misc/client_support.yaml +185 -0
- data/premailer-plus.gemspec +88 -0
- data/test/fixtures/client_support.html +10 -0
- data/test/fixtures/test-import.css +4 -0
- data/test/fixtures/test-with-folding.html +77 -0
- data/test/fixtures/test.css +90 -0
- data/test/fixtures/test.html +66 -0
- data/test/fixtures/test2.css +81 -0
- data/test/fixtures/test2.html +75 -0
- data/test/fixtures/test3-import.css +4 -0
- data/test/fixtures/test3-noimport.css +1 -0
- data/test/fixtures/test3-out.html +82 -0
- data/test/fixtures/test3.css +83 -0
- data/test/fixtures/test3.html +85 -0
- data/test/images/content_bg.jpg +0 -0
- data/test/images/inset.jpg +0 -0
- data/test/speed.rb +34 -0
- data/test/test_convert_to_plain_text.rb +30 -0
- data/test/test_helper.rb +6 -0
- data/test/test_link_resolver.rb +32 -0
- data/test/test_premailer.rb +22 -0
- data/test/test_premailer_download.rb +77 -0
- metadata +130 -0
data/.document
ADDED
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,42 @@
|
|
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.
|
data/README.textile
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
h1. premailer-plus
|
2
|
+
|
3
|
+
|*Misc. improvements by:*||Mike Green ("mike.is.green@gmail.com":mailto:mike.is.green@gmail.com)|
|
4
|
+
|*Original Premailer By:*||Alex Dunae ("http://www.dunae.ca":http://www.dunae.ca)|
|
5
|
+
|
6
|
+
Check out the Premailer web application at http://code.dunae.ca/premailer.web
|
7
|
+
|
8
|
+
h2. Background
|
9
|
+
|
10
|
+
Premailer Plus is my set of additions and corrections to Alex Dunae's excellent "Premailer":http://code.dunae.ca/premailer.web/ application. Premailer is "Preflight for HTML email", i.e. it takes your HTML code, and makes it compatible with most email clients. Email clients aren't like web browsers; their support for CSS is spotty and erratic, and they don't always render your code the way you'd expect. Premailer takes your CSS and moves it inline to each matched HTML element, and it displays warnings if you use code that's not well supported by email clients. "Campaign Monitor":http://www.campaignmonitor.com sponsors and uses Premailer to get your code ready for mass emails. It's a one of a kind, indispensible service, but I've always had a few gripes about the Premailer Rubygem, so I finally decided to fork the code and apply my fixes and improvements.
|
11
|
+
|
12
|
+
h3. Problems with "Premailer Classic"
|
13
|
+
|
14
|
+
I downloaded the Premailer gem so that I could rock the excellence of Premailer on my Mac instead of having to rely on an external service. There's even a TextMate bundle available that lets you Premailer you code right in the editor. But when I downloaded it, I found one glaring problem: *When you run Premailer from the command line, it throws an error when you give it a local file instead of a URL.* Kind of defeats the purpose.
|
15
|
+
|
16
|
+
h3. Improvements in Premailer Plus
|
17
|
+
|
18
|
+
* I fixed the command line script so that it no longer throws errors when you feed it a local file
|
19
|
+
* New option to shorten the URLs of links in your plain text version
|
20
|
+
** "Bit.ly":http://bit.ly
|
21
|
+
* More to come!
|
22
|
+
|
23
|
+
h2. Installation
|
24
|
+
|
25
|
+
The easiest way to install Premailer Plus is to run the following code on your OS X or Linux terminal (should work on Windows too, but I'm not sure):
|
26
|
+
|
27
|
+
<pre>
|
28
|
+
$ sudo gem install mikedamage-premailer-plus
|
29
|
+
</pre>
|
30
|
+
|
31
|
+
You can also clone this repository and run @rake build@ from the project root. Then run @sudo gem install ./pkg/premailer-plus*.gem@
|
32
|
+
|
33
|
+
h2. Dependencies
|
34
|
+
|
35
|
+
* "RubyGems":http://rubygems.rubyforge.org
|
36
|
+
* "Hpricot":http://wiki.github.com/why/hpricot
|
37
|
+
* "css-parser":http://code.dunae.ca/css_parser/
|
38
|
+
* "text-reform":http://rubyforge.org/projects/text-format/
|
39
|
+
* "Bitly4r":http://wiki.cantremember.com/Bitly4R/HomePage
|
40
|
+
|
41
|
+
h2. Copyright
|
42
|
+
|
43
|
+
Since I only improved Premailer's handling of local files and gave it the ability to shorten URL's, the copyright to this code is still held by Alex Dunae. I am only modifying and redistributing his code in compliance with the terms of Premailer's license.
|
data/Rakefile
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "premailer-plus"
|
8
|
+
gem.summary = %Q{Miscellaneous improvements and fixes to Alex Dunae's Premailer gem}
|
9
|
+
gem.email = "mike.is.green@gmail.com"
|
10
|
+
gem.homepage = "http://github.com/mikedamage/premailer-plus"
|
11
|
+
gem.authors = ["Mike Green"]
|
12
|
+
|
13
|
+
gem.add_dependency("hpricot", ">= 0.8.1")
|
14
|
+
gem.add_dependency("htmlentities", ">= 4.1.0")
|
15
|
+
gem.add_dependency("css_parser", ">= 0.9.0")
|
16
|
+
gem.add_dependency("text-reform", ">= 0.2.0")
|
17
|
+
|
18
|
+
Jeweler::GemcutterTasks.new
|
19
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
20
|
+
end
|
21
|
+
rescue LoadError
|
22
|
+
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
23
|
+
end
|
24
|
+
|
25
|
+
require 'rake/testtask'
|
26
|
+
Rake::TestTask.new(:test) do |test|
|
27
|
+
test.libs << 'lib' << 'test'
|
28
|
+
test.pattern = 'test/**/*_test.rb'
|
29
|
+
test.verbose = true
|
30
|
+
end
|
31
|
+
|
32
|
+
begin
|
33
|
+
require 'rcov/rcovtask'
|
34
|
+
Rcov::RcovTask.new do |test|
|
35
|
+
test.libs << 'test'
|
36
|
+
test.pattern = 'test/**/*_test.rb'
|
37
|
+
test.verbose = true
|
38
|
+
end
|
39
|
+
rescue LoadError
|
40
|
+
task :rcov do
|
41
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
task :default => :test
|
47
|
+
|
48
|
+
require 'rake/rdoctask'
|
49
|
+
Rake::RDocTask.new do |rdoc|
|
50
|
+
if File.exist?('VERSION.yml')
|
51
|
+
config = YAML.load(File.read('VERSION.yml'))
|
52
|
+
version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
|
53
|
+
else
|
54
|
+
version = ""
|
55
|
+
end
|
56
|
+
|
57
|
+
rdoc.rdoc_dir = 'rdoc'
|
58
|
+
rdoc.title = "premailer-plus #{version}"
|
59
|
+
rdoc.rdoc_files.include('README*')
|
60
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
61
|
+
end
|
62
|
+
|
data/VERSION.yml
ADDED
data/bin/premailer_plus
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# = Premailer Plus
|
4
|
+
#
|
5
|
+
# Miscellaneous improvements to Alex Dunae's Premailer gem
|
6
|
+
|
7
|
+
require "optparse"
|
8
|
+
require "optparse/time"
|
9
|
+
require "ostruct"
|
10
|
+
require "pathname"
|
11
|
+
require File.join(File.dirname(__FILE__), "../lib/premailer")
|
12
|
+
|
13
|
+
class PremailerApp
|
14
|
+
BANNER = <<-END
|
15
|
+
Premailer Plus
|
16
|
+
-----------------
|
17
|
+
|
18
|
+
Usage:
|
19
|
+
FILE [options] inputfile outputfile
|
20
|
+
"inputfile" can be either a local file or a URL
|
21
|
+
|
22
|
+
Options:
|
23
|
+
END
|
24
|
+
|
25
|
+
def initialize(args)
|
26
|
+
@args = args
|
27
|
+
@config = OpenStruct.new({
|
28
|
+
:plaintext => false,
|
29
|
+
:shorten_urls => false,
|
30
|
+
:base_url => '',
|
31
|
+
:infile => nil,
|
32
|
+
:outfile => '',
|
33
|
+
:querystring => '',
|
34
|
+
:warnings => false,
|
35
|
+
:verbose => true
|
36
|
+
})
|
37
|
+
end
|
38
|
+
|
39
|
+
def run
|
40
|
+
if parse_options and get_arguments
|
41
|
+
@pm_opts = {}
|
42
|
+
@pm_opts.merge!({:base_url => @config.base_url}) unless @config.base_url.empty?
|
43
|
+
@pm_opts.merge!({:link_query_string => @config.querystring}) unless @config.querystring.empty?
|
44
|
+
@pm_opts.merge!({:shorten_urls => true}) if @config.shorten_urls
|
45
|
+
@premailer = Premailer.new(@config.infile.to_s, @pm_opts)
|
46
|
+
|
47
|
+
write_premail_html
|
48
|
+
write_plain_text if @config.plaintext
|
49
|
+
show_warnings if @config.warnings
|
50
|
+
else
|
51
|
+
puts "Invalid options/arguments!"
|
52
|
+
exit 1
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
def parse_options
|
58
|
+
opts = OptionParser.new
|
59
|
+
opts.banner = BANNER.gsub!(/FILE/, File.basename(__FILE__))
|
60
|
+
opts.on("-p", "--plaintext", "Output a plain-text version of the email in addition to HTML") { @config.plaintext = true }
|
61
|
+
opts.on("-s", "--shorten-urls", "Shorten URLs with Bit.ly in the plain-text version") { @config.shorten_urls = true }
|
62
|
+
opts.on("-b", "--baseurl URL", "Prepend this URL to links") {|url| @config.base_url = url }
|
63
|
+
opts.on("-q", "--querystring STRING", "Append this query string to link URLs") {|string| @config.querystring = string }
|
64
|
+
opts.on("-v", "--verbose", "Display status information during the pre-mailing process") { @config.verbose = true }
|
65
|
+
opts.on("-w", "--warnings", "Display CSS support warnings") { @config.warnings = true }
|
66
|
+
opts.on("-h", "--help", "Show this information") { puts opts; exit 0; }
|
67
|
+
true if opts.parse!(@args) rescue return false
|
68
|
+
end
|
69
|
+
|
70
|
+
def get_arguments
|
71
|
+
if @args.nitems >= 2
|
72
|
+
@config.infile = Pathname.new(@args.shift)
|
73
|
+
@config.outfile = Pathname.new(@args.shift)
|
74
|
+
return true
|
75
|
+
else
|
76
|
+
return false
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def write_premail_html
|
81
|
+
puts "Converting HTML to inline CSS and saving as #{@config.outfile.basename.to_s}..." if @config.verbose
|
82
|
+
@config.outfile.open("w") do |file|
|
83
|
+
file.write(@premailer.to_inline_css)
|
84
|
+
end
|
85
|
+
puts "Done." if @config.verbose
|
86
|
+
end
|
87
|
+
|
88
|
+
def write_plain_text
|
89
|
+
textfile = Pathname.new(File.join(@config.outfile.dirname.to_s, @config.outfile.basename.to_s.gsub(/\..+$/, ".txt")))
|
90
|
+
puts "Saving a plain-text version of your email as #{@config.outfile.basename.to_s.gsub(/\.html$/, '.txt')}..." if @config.verbose
|
91
|
+
puts "Shortening link URLs with Bit.ly..." if @config.verbose and @config.shorten_urls
|
92
|
+
textfile.open("w") do |file|
|
93
|
+
file.write(@premailer.to_plain_text)
|
94
|
+
end
|
95
|
+
puts "Done." if @config.verbose
|
96
|
+
end
|
97
|
+
|
98
|
+
def show_warnings
|
99
|
+
puts "#{@premailer.warnings.nitems.to_s} warnings found:"
|
100
|
+
@premailer.warnings.each do |warning|
|
101
|
+
puts warning
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
app = PremailerApp.new(ARGV)
|
107
|
+
app.run
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'text/reform'
|
2
|
+
require 'htmlentities'
|
3
|
+
require 'bitly4r'
|
4
|
+
|
5
|
+
# Support functions for Premailer
|
6
|
+
module HtmlToPlainText
|
7
|
+
|
8
|
+
# Returns the text in UTF-8 format with all HTML tags removed
|
9
|
+
#
|
10
|
+
# TODO:
|
11
|
+
# - add support for DL, OL
|
12
|
+
def convert_to_text(html, line_length, from_charset = 'UTF-8', shorten = false)
|
13
|
+
r = Text::Reform.new(:trim => true,
|
14
|
+
:squeeze => false,
|
15
|
+
:break => Text::Reform.break_wrap)
|
16
|
+
|
17
|
+
txt = html
|
18
|
+
|
19
|
+
bitly = shorten ? Bitly4R.Keyed("mikedamage", "R_abb45e99634386334b7ed6c8d081e80e") : nil
|
20
|
+
|
21
|
+
he = HTMLEntities.new # decode HTML entities
|
22
|
+
|
23
|
+
txt = he.decode(txt)
|
24
|
+
|
25
|
+
txt.gsub!(/<h([0-9]+)[^>]*>(.*)<\/h[0-9]+>/i) do |s| # handle headings
|
26
|
+
hlevel = $1.to_i
|
27
|
+
htext = $2.gsub(/<\/?[^>]*>/i, '') # remove tags inside headings
|
28
|
+
hlength = (htext.length > line_length ?
|
29
|
+
line_length :
|
30
|
+
htext.length)
|
31
|
+
|
32
|
+
case hlevel
|
33
|
+
when 1 # H1
|
34
|
+
('*' * hlength) + "\n" + htext + "\n" + ('*' * hlength) + "\n"
|
35
|
+
when 2 # H2
|
36
|
+
('-' * hlength) + "\n" + htext + "\n" + ('-' * hlength) + "\n"
|
37
|
+
else # H3-H6 are styled the same
|
38
|
+
htext + "\n" + ('-' * htext.length) + "\n"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
txt.gsub!(/<a.*href=\"([^\"]*)\"[^>]*>(.*)<\/a>/i) do |s| # links
|
43
|
+
if bitly
|
44
|
+
$2 + ' [' + bitly.shorten($1) + ']'
|
45
|
+
else
|
46
|
+
$2 + ' [' + $1 + ']'
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
txt.gsub!(/(<li[\s]+[^>]*>|<li>)/i, ' * ') # unordered LIsts
|
51
|
+
txt.gsub!(/<\/p>/i, "\n\n") # paragraphs
|
52
|
+
|
53
|
+
txt.gsub!(/<\/?[^>]*>/, '') # strip remaining tags
|
54
|
+
txt.gsub!(/\A[\s]+|[\s]+\Z|^[ \t]+/m, '') # strip extra spaces
|
55
|
+
txt.gsub!(/[\n]{3,}/m, "\n\n") # tighten line breaks
|
56
|
+
|
57
|
+
txt = r.format(('[' * line_length), txt) # wrap text
|
58
|
+
txt.gsub!(/^[\*][\s]/m, ' * ') # add spaces back to lists
|
59
|
+
txt
|
60
|
+
end
|
61
|
+
end
|
data/lib/premailer.rb
ADDED
@@ -0,0 +1,401 @@
|
|
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"] = "/Library/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
|
+
require 'tempfile'
|
16
|
+
|
17
|
+
require 'html_to_plain_text'
|
18
|
+
|
19
|
+
# Premailer processes HTML and CSS to improve e-mail deliverability.
|
20
|
+
#
|
21
|
+
# Premailer's main function is to render all CSS as inline <tt>style</tt> attributes using
|
22
|
+
# the CssParser. It can also convert relative links to absolute links and check the 'safety' of
|
23
|
+
# CSS properties against a CSS support chart.
|
24
|
+
#
|
25
|
+
# = Example
|
26
|
+
#
|
27
|
+
# premailer = Premailer.new(html_file, :warn_level => Premailer::Warnings::SAFE)
|
28
|
+
# premailer.parse!
|
29
|
+
# puts premailer.warnings.length.to_s + ' warnings found'
|
30
|
+
class Premailer
|
31
|
+
include HtmlToPlainText
|
32
|
+
include CssParser
|
33
|
+
|
34
|
+
CLIENT_SUPPORT_FILE = File.dirname(__FILE__) + '/../misc/client_support.yaml'
|
35
|
+
|
36
|
+
RE_UNMERGABLE_SELECTORS = /(\:(visited|active|hover|focus|after|before|selection|target|first\-(line|letter))|^\@)/i
|
37
|
+
|
38
|
+
# should also exclude :first-letter, etc...
|
39
|
+
|
40
|
+
# URI of the HTML file used
|
41
|
+
attr_reader :html_file
|
42
|
+
|
43
|
+
module Warnings
|
44
|
+
NONE = 0
|
45
|
+
SAFE = 1
|
46
|
+
POOR = 2
|
47
|
+
RISKY = 3
|
48
|
+
end
|
49
|
+
include Warnings
|
50
|
+
|
51
|
+
WARN_LABEL = %w(NONE SAFE POOR RISKY)
|
52
|
+
|
53
|
+
# Create a new Premailer object.
|
54
|
+
#
|
55
|
+
# +uri+ is the URL of the HTML file to process. Should be a string.
|
56
|
+
#
|
57
|
+
# ==== Options
|
58
|
+
# [+line_length+] Line length used by to_plain_text. Boolean, default is 65.
|
59
|
+
# [+warn_level+] What level of CSS compatibility warnings to show (see Warnings).
|
60
|
+
# [+link_query_string+] A string to append to every <a href=""> link.
|
61
|
+
def initialize(uri, options = {})
|
62
|
+
@options = {:warn_level => Warnings::SAFE, :line_length => 65, :link_query_string => nil, :base_url => nil, :shorten_urls => false}.merge(options)
|
63
|
+
@html_file = uri
|
64
|
+
|
65
|
+
|
66
|
+
@is_local_file = true
|
67
|
+
if uri =~ /^(http|https|ftp)\:\/\//i
|
68
|
+
@is_local_file = false
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
@css_warnings = []
|
73
|
+
|
74
|
+
@css_parser = CssParser::Parser.new({:absolute_paths => true,
|
75
|
+
:import => true,
|
76
|
+
:io_exceptions => false
|
77
|
+
})
|
78
|
+
|
79
|
+
@doc, @html_charset = load_html(@html_file)
|
80
|
+
|
81
|
+
if @is_local_file and @options[:base_url]
|
82
|
+
@doc = convert_inline_links(@doc, @options[:base_url])
|
83
|
+
elsif not @is_local_file
|
84
|
+
@doc = convert_inline_links(@doc, @html_file)
|
85
|
+
end
|
86
|
+
load_css_from_html!
|
87
|
+
end
|
88
|
+
|
89
|
+
# Array containing a hash of CSS warnings.
|
90
|
+
def warnings
|
91
|
+
return [] if @options[:warn_level] == Warnings::NONE
|
92
|
+
@css_warnings = check_client_support if @css_warnings.empty?
|
93
|
+
@css_warnings
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns the original HTML as a string.
|
97
|
+
def to_s
|
98
|
+
@doc.to_html
|
99
|
+
end
|
100
|
+
|
101
|
+
# Returns the document with all HTML tags removed.
|
102
|
+
def to_plain_text
|
103
|
+
html_src = ''
|
104
|
+
begin
|
105
|
+
html_src = @doc.search("body").innerHTML
|
106
|
+
rescue
|
107
|
+
html_src = @doc.to_html
|
108
|
+
end
|
109
|
+
if @options[:shorten_urls]
|
110
|
+
convert_to_text(html_src, @options[:line_length], @html_charset, true)
|
111
|
+
else
|
112
|
+
convert_to_text(html_src, @options[:line_length], @html_charset, false)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Merge CSS into the HTML document.
|
117
|
+
#
|
118
|
+
# Returns a string.
|
119
|
+
def to_inline_css
|
120
|
+
doc = @doc
|
121
|
+
unmergable_rules = CssParser::Parser.new
|
122
|
+
|
123
|
+
# Give all styles already in style attributes a specificity of 1000
|
124
|
+
# per http://www.w3.org/TR/CSS21/cascade.html#specificity
|
125
|
+
doc.search("*[@style]").each do |el|
|
126
|
+
el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]'
|
127
|
+
end
|
128
|
+
|
129
|
+
# Iterate through the rules and merge them into the HTML
|
130
|
+
@css_parser.each_selector(:all) do |selector, declaration, specificity|
|
131
|
+
# Save un-mergable rules separately
|
132
|
+
selector.gsub!(/:link([\s]|$)+/i, '')
|
133
|
+
|
134
|
+
# Convert element names to lower case
|
135
|
+
selector.gsub!(/([\s]|^)([\w]+)/) {|m| $1.to_s + $2.to_s.downcase }
|
136
|
+
|
137
|
+
if selector =~ RE_UNMERGABLE_SELECTORS
|
138
|
+
unmergable_rules.add_rule_set!(RuleSet.new(selector, declaration))
|
139
|
+
else
|
140
|
+
|
141
|
+
doc.search(selector) do |el|
|
142
|
+
if el.elem?
|
143
|
+
# Add a style attribute or append to the existing one
|
144
|
+
block = "[SPEC=#{specificity}[#{declaration}]]"
|
145
|
+
el['style'] = (el.attributes['style'] ||= '') + ' ' + block
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Read <style> attributes and perform folding
|
152
|
+
doc.search("*[@style]").each do |el|
|
153
|
+
style = el.attributes['style'].to_s
|
154
|
+
|
155
|
+
declarations = []
|
156
|
+
|
157
|
+
style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration|
|
158
|
+
rs = RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i)
|
159
|
+
declarations << rs
|
160
|
+
end
|
161
|
+
|
162
|
+
# Perform style folding and save
|
163
|
+
merged = CssParser.merge(declarations)
|
164
|
+
|
165
|
+
el['style'] = Premailer.escape_string(merged.declarations_to_s)
|
166
|
+
end
|
167
|
+
|
168
|
+
doc = write_unmergable_css_rules(doc, unmergable_rules)
|
169
|
+
|
170
|
+
#doc = add_body_imposter(doc)
|
171
|
+
|
172
|
+
doc.to_html
|
173
|
+
end
|
174
|
+
|
175
|
+
|
176
|
+
protected
|
177
|
+
# Load the HTML file and convert it into an Hpricot document.
|
178
|
+
#
|
179
|
+
# Returns an Hpricot document and a string with the HTML file's character set.
|
180
|
+
def load_html(uri)
|
181
|
+
if @is_local_file
|
182
|
+
Hpricot(File.open(uri, "r") {|f| f.read })
|
183
|
+
else
|
184
|
+
@temp_file = Tempfile.new("premailer_plus")
|
185
|
+
@temp_file.write(open(uri).read)
|
186
|
+
@temp_file.close
|
187
|
+
@is_local_file = true
|
188
|
+
Hpricot(@temp_file.open.read)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Load CSS included in <tt>style</tt> and <tt>link</tt> tags from an HTML document.
|
193
|
+
def load_css_from_html!
|
194
|
+
if tags = @doc.search("link[@rel='stylesheet'], style")
|
195
|
+
tags.each do |tag|
|
196
|
+
if tag.to_s.strip =~ /^\<link/i and tag.attributes['href']
|
197
|
+
if media_type_ok?(tag.attributes['media'])
|
198
|
+
link_uri = Premailer.resolve_link(tag.attributes['href'].to_s, @html_file)
|
199
|
+
if @is_local_file
|
200
|
+
css_block = ''
|
201
|
+
File.open(link_uri, "r") do |file|
|
202
|
+
while line = file.gets
|
203
|
+
css_block << line
|
204
|
+
end
|
205
|
+
end
|
206
|
+
@css_parser.add_block!(css_block, {:base_uri => @html_file})
|
207
|
+
else
|
208
|
+
@css_parser.load_uri!(link_uri)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
elsif tag.to_s.strip =~ /^\<style/i
|
212
|
+
@css_parser.add_block!(tag.innerHTML, :base_uri => URI.parse(@html_file))
|
213
|
+
end
|
214
|
+
end
|
215
|
+
tags.remove
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def media_type_ok?(media_types)
|
220
|
+
return media_types.split(/[\s]+|,/).any? { |media_type| media_type.strip =~ /screen|handheld|all/i }
|
221
|
+
rescue
|
222
|
+
return true
|
223
|
+
end
|
224
|
+
|
225
|
+
# Create a <tt>style</tt> element with un-mergable rules (e.g. <tt>:hover</tt>)
|
226
|
+
# and write it into the <tt>body</tt>.
|
227
|
+
#
|
228
|
+
# <tt>doc</tt> is an Hpricot document and <tt>unmergable_css_rules</tt> is a Css::RuleSet.
|
229
|
+
#
|
230
|
+
# Returns an Hpricot document.
|
231
|
+
def write_unmergable_css_rules(doc, unmergable_rules)
|
232
|
+
styles = ''
|
233
|
+
unmergable_rules.each_selector(:all, :force_important => true) do |selector, declarations, specificity|
|
234
|
+
styles += "#{selector} { #{declarations} }\n"
|
235
|
+
end
|
236
|
+
unless styles.empty?
|
237
|
+
style_tag = "\n<style type=\"text/css\">\n#{styles}</style>\n"
|
238
|
+
doc.search("head").append(style_tag)
|
239
|
+
end
|
240
|
+
doc
|
241
|
+
end
|
242
|
+
|
243
|
+
# Convert relative links to absolute links.
|
244
|
+
#
|
245
|
+
# Processes <tt>href</tt> <tt>src</tt> and <tt>background</tt> attributes
|
246
|
+
# as well as CSS <tt>url()</tt> declarations found in inline <tt>style</tt> attributes.
|
247
|
+
#
|
248
|
+
# <tt>doc</tt> is an Hpricot document and <tt>base_uri</tt> is either a string or a URI.
|
249
|
+
#
|
250
|
+
# Returns an Hpricot document.
|
251
|
+
def convert_inline_links(doc, base_uri)
|
252
|
+
base_uri = URI.parse(base_uri) unless base_uri.kind_of?(URI)
|
253
|
+
|
254
|
+
['href', 'src', 'background'].each do |attribute|
|
255
|
+
|
256
|
+
tags = doc.search("*[@#{attribute}]")
|
257
|
+
append_qs = @options[:link_query_string] ||= ''
|
258
|
+
unless tags.empty?
|
259
|
+
tags.each do |tag|
|
260
|
+
unless tag.attributes[attribute] =~ /^(\{|\[|<|\#)/i
|
261
|
+
if tag.attributes[attribute] =~ /^http/i
|
262
|
+
begin
|
263
|
+
merged = URI.parse(tag.attributes[attribute])
|
264
|
+
rescue
|
265
|
+
next
|
266
|
+
end
|
267
|
+
else
|
268
|
+
begin
|
269
|
+
merged = Premailer.resolve_link(tag.attributes[attribute].to_s, base_uri.merge)
|
270
|
+
rescue
|
271
|
+
begin
|
272
|
+
merged = Premailer.resolve_link(URI.escape(tag.attributes[attribute].to_s), base_uri.merge)
|
273
|
+
# merged = base_uri.merge(URI.escape(tag.attributes[attribute].to_s))
|
274
|
+
rescue; end
|
275
|
+
end
|
276
|
+
end # end of relative urls only
|
277
|
+
|
278
|
+
if tag.name =~ /^a$/i and not append_qs.empty?
|
279
|
+
if merged.query
|
280
|
+
merged.query = merged.query + '&' + append_qs
|
281
|
+
else
|
282
|
+
merged.query = append_qs
|
283
|
+
end
|
284
|
+
end
|
285
|
+
tag[attribute] = merged
|
286
|
+
#puts merged.inspect
|
287
|
+
end # end of skipping special chars
|
288
|
+
|
289
|
+
|
290
|
+
end # end of each tag
|
291
|
+
end # end of empty
|
292
|
+
end # end of attrs
|
293
|
+
|
294
|
+
doc.search("*[@style]").each do |el|
|
295
|
+
el['style'] = CssParser.convert_uris(el.attributes['style'].to_s, base_uri)
|
296
|
+
end
|
297
|
+
doc
|
298
|
+
end
|
299
|
+
|
300
|
+
def self.escape_string(str)
|
301
|
+
str.gsub(/"/, "'")
|
302
|
+
end
|
303
|
+
|
304
|
+
def self.resolve_link(path, base_path)
|
305
|
+
if base_path.kind_of?(URI)
|
306
|
+
base_path.merge!(path)
|
307
|
+
return Premailer.canonicalize(base_path)
|
308
|
+
elsif base_path.kind_of?(String) and base_path =~ /^(http[s]?|ftp):\/\//i
|
309
|
+
base_uri = URI.parse(base_path)
|
310
|
+
base_uri.merge!(path)
|
311
|
+
return Premailer.canonicalize(base_uri)
|
312
|
+
else
|
313
|
+
|
314
|
+
return File.expand_path(path, File.dirname(base_path))
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
# from http://www.ruby-forum.com/topic/140101
|
319
|
+
def self.canonicalize(uri)
|
320
|
+
u = uri.kind_of?(URI) ? uri : URI.parse(uri.to_s)
|
321
|
+
u.normalize!
|
322
|
+
newpath = u.path
|
323
|
+
while newpath.gsub!(%r{([^/]+)/\.\./?}) { |match|
|
324
|
+
$1 == '..' ? match : ''
|
325
|
+
} do end
|
326
|
+
newpath = newpath.gsub(%r{/\./}, '/').sub(%r{/\.\z}, '/')
|
327
|
+
u.path = newpath
|
328
|
+
u.to_s
|
329
|
+
end
|
330
|
+
|
331
|
+
|
332
|
+
|
333
|
+
def add_body_imposter(doc)
|
334
|
+
newdoc = doc
|
335
|
+
if body_tag = newdoc.at("body") and body_tag.attributes["style"]
|
336
|
+
body_html = body_tag.inner_html
|
337
|
+
body_tag.inner_html = "\n<div id=\"premailer_body_wrapper\">\n#{body_html}\n</div>\n"
|
338
|
+
if body_tag.attributes["style"]
|
339
|
+
newdoc.at("#premailer_body_wrapper")["style"] = body_tag.attributes["style"].to_s
|
340
|
+
newdoc.at("body")["style"] = "margin: 0; padding: 0;"
|
341
|
+
end
|
342
|
+
|
343
|
+
end
|
344
|
+
return newdoc
|
345
|
+
rescue
|
346
|
+
return doc
|
347
|
+
end
|
348
|
+
|
349
|
+
|
350
|
+
# Check <tt>CLIENT_SUPPORT_FILE</tt> for any CSS warnings
|
351
|
+
def check_client_support
|
352
|
+
@client_support = @client_support ||= YAML::load(File.open(CLIENT_SUPPORT_FILE))
|
353
|
+
|
354
|
+
warnings = []
|
355
|
+
properties = []
|
356
|
+
|
357
|
+
# Get a list off CSS properties
|
358
|
+
@doc.search("*[@style]").each do |el|
|
359
|
+
style_url = el.attributes['style'].gsub(/([\w\-]+)[\s]*\:/i) do |s|
|
360
|
+
properties.push($1)
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
properties.uniq!
|
365
|
+
|
366
|
+
property_support = @client_support['css_properties']
|
367
|
+
properties.each do |prop|
|
368
|
+
if property_support.include?(prop) and property_support[prop]['support'] >= @options[:warn_level]
|
369
|
+
warnings.push({:message => "#{prop} CSS property",
|
370
|
+
:level => WARN_LABEL[property_support[prop]['support']],
|
371
|
+
:clients => property_support[prop]['unsupported_in'].join(', ')})
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
@client_support['attributes'].each do |attribute, data|
|
376
|
+
next unless data['support'] >= @options[:warn_level]
|
377
|
+
if @doc.search("*[@#{attribute}]").length > 0
|
378
|
+
warnings.push({:message => "#{attribute} HTML attribute",
|
379
|
+
:level => WARN_LABEL[property_support[prop]['support']],
|
380
|
+
:clients => property_support[prop]['unsupported_in'].join(', ')})
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
@client_support['elements'].each do |element, data|
|
385
|
+
next unless data['support'] >= @options[:warn_level]
|
386
|
+
if @doc.search("element").length > 0
|
387
|
+
warnings.push({:message => "#{element} HTML element",
|
388
|
+
:level => WARN_LABEL[property_support[prop]['support']],
|
389
|
+
:clients => property_support[prop]['unsupported_in'].join(', ')})
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
|
394
|
+
|
395
|
+
|
396
|
+
|
397
|
+
return warnings
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
|