premailer 1.6.2 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -15,10 +15,13 @@ script is my solution.
15
15
  * A plain text version is created
16
16
  Optional
17
17
 
18
+ === Premailer 2.0 is coming
19
+
20
+ I'm looking for input on a version 2.0 update to Premailer. PLease visit the {Premailer 2.0 Planning Page}[https://github.com/alexdunae/premailer/wiki/Premailer-2.0-Planning] and give me your feedback.
18
21
 
19
22
  === Installation
20
23
 
21
- Download the Premailer gem from GemCutter.
24
+ Download the Premailer gem from RubyGems.
22
25
 
23
26
  sudo gem install premailer
24
27
 
@@ -42,7 +45,7 @@ Download the Premailer gem from GemCutter.
42
45
 
43
46
  === Contributions
44
47
 
45
- Contributions are most welcome. Premailer was rotting away in a private SVN repository for too long and could use some TLC. Pull and patch to your heart's content.
48
+ Contributions are most welcome. Premailer was rotting away in a private SVN repository for too long and could use some TLC. Fork and patch to your heart's content. Please don't increment the version numbers, though.
46
49
 
47
50
  A few areas that are particularly in need of love:
48
51
  * Testing suite
@@ -54,13 +57,13 @@ A few areas that are particularly in need of love:
54
57
 
55
58
  === Credits and code
56
59
 
57
- Thanks to {all the wonderful contributors}[http://github.com/alexdunae/premailer/contributors] for their updates.
60
+ Thanks to {all the wonderful contributors}[https://github.com/alexdunae/premailer/contributors] for their updates.
58
61
 
59
62
  Thanks to {Greenhood + Company}[http://www.greenhood.com/] for sponsoring some of the 1.5.6 updates,
60
63
  and to {Campaign Monitor}[http://www.campaignmonitor.com] for supporting the web interface.
61
64
 
62
- The web interface can be found at http://premailer.dialect.ca .
65
+ The web interface can be found at {premailer.dialect.ca}[http://premailer.dialect.ca].
63
66
 
64
- The source code can be found at http://github.com/alexdunae/premailer .
67
+ The source code can be found on {GitHub}[https://github.com/alexdunae/premailer].
65
68
 
66
- Written by Alex Dunae (dunae.ca, e-mail 'code' at the same domain), 2008-2010.
69
+ Copyright by Alex Dunae (dunae.ca, e-mail 'code' at the same domain), 2007-2011. See LICENSE.rdoc for license details.
@@ -31,7 +31,7 @@ opts = OptionParser.new do |opts|
31
31
  mode = v
32
32
  end
33
33
 
34
- opts.on("-b", "--base-url", String, "Base URL, useful for local files") do |v|
34
+ opts.on("-b", "--base-url STRING", String, "Base URL, useful for local files") do |v|
35
35
  options[:base_url] = v
36
36
  end
37
37
 
@@ -1,7 +1,11 @@
1
1
  require 'yaml'
2
2
  require 'open-uri'
3
+ require 'digest/md5'
3
4
  require 'cgi'
4
- require 'hpricot'
5
5
  require 'css_parser'
6
+ require File.expand_path(File.dirname(__FILE__) + '/premailer/adapter')
7
+ require File.expand_path(File.dirname(__FILE__) + '/premailer/adapter/hpricot')
8
+ require File.expand_path(File.dirname(__FILE__) + '/premailer/adapter/nokogiri')
9
+
6
10
  require File.expand_path(File.dirname(__FILE__) + '/premailer/html_to_plain_text')
7
11
  require File.expand_path(File.dirname(__FILE__) + '/premailer/premailer')
@@ -0,0 +1,49 @@
1
+ # = HTTPI::Adapter
2
+ #
3
+ # Manages the adapter classes. Currently supports:
4
+ #
5
+ # * nokogiri
6
+ # * hpricot
7
+ module Adapter
8
+ DEFAULT = :hpricot
9
+
10
+ # Returns the adapter to use. Defaults to <tt>Adapter::</tt>.
11
+ def self.use
12
+ @use ||= DEFAULT
13
+ end
14
+
15
+ # Sets the +adapter+ to use. Raises an +ArgumentError+ unless the +adapter+ exists.
16
+ def self.use=(adapter)
17
+ validate_adapter! adapter
18
+ @use = adapter
19
+ end
20
+
21
+ # Returns a memoized +Hash+ of adapters.
22
+ def self.adapters
23
+ @adapters ||= {
24
+ :nokogiri => { :class => Nokogiri, :require => "nokogiri" },
25
+ :hpricot => { :class => Hpricot, :require => "hpricot" },
26
+ }
27
+ end
28
+
29
+ # Returns an +adapter+. Raises an +ArgumentError+ unless the +adapter+ exists.
30
+ def self.find(adapter)
31
+ validate_adapter! adapter
32
+ load_adapter adapter
33
+ end
34
+
35
+ private
36
+
37
+ # Raises an +ArgumentError+ unless the +adapter+ exists.
38
+ def self.validate_adapter!(adapter)
39
+ raise ArgumentError, "Invalid adapter: #{adapter}" unless adapters[adapter]
40
+ end
41
+
42
+ # Tries to load and return the given +adapter+ name and class and falls back to the +FALLBACK+ adapter.
43
+ def self.load_adapter(adapter)
44
+ require adapters[adapter][:require]
45
+ [adapter, adapters[adapter][:class]]
46
+ rescue LoadError
47
+ puts "tried to use the #{adapter} adapter, but was unable to find the library in the LOAD_PATH."
48
+ end
49
+ end
@@ -0,0 +1,180 @@
1
+
2
+ module Adapter
3
+ module Hpricot
4
+
5
+ # Merge CSS into the HTML document.
6
+ #
7
+ # Returns a string.
8
+ def to_inline_css
9
+ doc = @processed_doc
10
+ @unmergable_rules = CssParser::Parser.new
11
+
12
+ # Give all styles already in style attributes a specificity of 1000
13
+ # per http://www.w3.org/TR/CSS21/cascade.html#specificity
14
+ doc.search("*[@style]").each do |el|
15
+ el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]'
16
+ end
17
+
18
+ # Iterate through the rules and merge them into the HTML
19
+ @css_parser.each_selector(:all) do |selector, declaration, specificity|
20
+ # Save un-mergable rules separately
21
+ selector.gsub!(/:link([\s]*)+/i) {|m| $1 }
22
+
23
+ # Convert element names to lower case
24
+ selector.gsub!(/([\s]|^)([\w]+)/) {|m| $1.to_s + $2.to_s.downcase }
25
+
26
+ if selector =~ Premailer::RE_UNMERGABLE_SELECTORS
27
+ @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration)) unless @options[:preserve_styles]
28
+ else
29
+ begin
30
+ # Change single ID CSS selectors into xpath so that we can match more
31
+ # than one element. Added to work around dodgy generated code.
32
+ selector.gsub!(/\A\#([\w_\-]+)\Z/, '*[@id=\1]')
33
+
34
+ doc.search(selector).each do |el|
35
+ if el.elem? and (el.name != 'head' and el.parent.name != 'head')
36
+ # Add a style attribute or append to the existing one
37
+ block = "[SPEC=#{specificity}[#{declaration}]]"
38
+ el['style'] = (el.attributes['style'].to_s ||= '') + ' ' + block
39
+ end
40
+ end
41
+ rescue Hpricot::Error, RuntimeError, ArgumentError
42
+ $stderr.puts "CSS syntax error with selector: #{selector}" if @options[:verbose]
43
+ next
44
+ end
45
+ end
46
+ end
47
+
48
+ # Read STYLE attributes and perform folding
49
+ doc.search("*[@style]").each do |el|
50
+ style = el.attributes['style'].to_s
51
+
52
+ declarations = []
53
+
54
+ style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration|
55
+ rs = CssParser::RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i)
56
+ declarations << rs
57
+ end
58
+
59
+ # Perform style folding
60
+ merged = CssParser.merge(declarations)
61
+ merged.expand_shorthand!
62
+
63
+ # Duplicate CSS attributes as HTML attributes
64
+ if Premailer::RELATED_ATTRIBUTES.has_key?(el.name)
65
+ Premailer::RELATED_ATTRIBUTES[el.name].each do |css_att, html_att|
66
+ el[html_att] = merged[css_att].gsub(/;$/, '').strip if el[html_att].nil? and not merged[css_att].empty?
67
+ end
68
+ end
69
+
70
+ merged.create_dimensions_shorthand!
71
+
72
+ # write the inline STYLE attribute
73
+ el['style'] = Premailer.escape_string(merged.declarations_to_s)
74
+ end
75
+
76
+ doc = write_unmergable_css_rules(doc, @unmergable_rules)
77
+
78
+ if @options[:remove_classes] or @options[:remove_comments]
79
+ doc.search('*').each do |el|
80
+ if el.comment? and @options[:remove_comments]
81
+ lst = el.parent.children
82
+ el.parent = nil
83
+ lst.delete(el)
84
+ elsif el.elem?
85
+ el.remove_attribute('class') if @options[:remove_classes]
86
+ end
87
+ end
88
+ end
89
+
90
+ if @options[:remove_ids]
91
+ # find all anchor's targets and hash them
92
+ targets = []
93
+ doc.search("a[@href^='#']").each do |el|
94
+ target = el.get_attribute('href')[1..-1]
95
+ targets << target
96
+ el.set_attribute('href', "#" + Digest::MD5.hexdigest(target))
97
+ end
98
+ # hash ids that are links target, delete others
99
+ doc.search("*[@id]").each do |el|
100
+ id = el.get_attribute('id')
101
+ if targets.include?(id)
102
+ el.set_attribute('id', Digest::MD5.hexdigest(id))
103
+ else
104
+ el.remove_attribute('id')
105
+ end
106
+ end
107
+ end
108
+
109
+ @processed_doc = doc
110
+
111
+ @processed_doc.to_original_html
112
+ end
113
+
114
+ # Create a <tt>style</tt> element with un-mergable rules (e.g. <tt>:hover</tt>)
115
+ # and write it into the <tt>body</tt>.
116
+ #
117
+ # <tt>doc</tt> is an Hpricot document and <tt>unmergable_css_rules</tt> is a Css::RuleSet.
118
+ #
119
+ # Returns an Hpricot document.
120
+ def write_unmergable_css_rules(doc, unmergable_rules) # :nodoc:
121
+ if head = doc.search('head')
122
+ styles = ''
123
+ unmergable_rules.each_selector(:all, :force_important => true) do |selector, declarations, specificity|
124
+ styles += "#{selector} { #{declarations} }\n"
125
+ end
126
+
127
+ unless styles.empty?
128
+ style_tag = "\n<style type=\"text/css\">\n#{styles}</style>\n"
129
+ head.html.empty? ? head.inner_html(style_tag) : head.append(style_tag)
130
+ end
131
+ else
132
+ $stderr.puts "Unable to write unmergable CSS rules: no <head> was found" if @options[:verbose]
133
+ end
134
+ doc
135
+ end
136
+
137
+
138
+ # Converts the HTML document to a format suitable for plain-text e-mail.
139
+ #
140
+ # If present, uses the <body> element as its base; otherwise uses the whole document.
141
+ #
142
+ # Returns a string.
143
+ def to_plain_text
144
+ html_src = ''
145
+ begin
146
+ html_src = @doc.search("body").inner_html
147
+ rescue; end
148
+
149
+ html_src = @doc.to_html unless html_src and not html_src.empty?
150
+ convert_to_text(html_src, @options[:line_length], @html_encoding)
151
+ end
152
+
153
+
154
+ # Returns the original HTML as a string.
155
+ def to_s
156
+ @doc.to_original_html
157
+ end
158
+
159
+ # Load the HTML file and convert it into an Hpricot document.
160
+ #
161
+ # Returns an Hpricot document.
162
+ def load_html(input) # :nodoc:
163
+ thing = nil
164
+
165
+ # TODO: duplicate options
166
+ if @options[:with_html_string] or @options[:inline] or input.respond_to?(:read)
167
+ thing = input
168
+ elsif @is_local_file
169
+ @base_dir = File.dirname(input)
170
+ thing = File.open(input, 'r')
171
+ else
172
+ thing = open(input)
173
+ end
174
+
175
+ # TODO: deal with Hpricot seg faults on empty input
176
+ thing ? Hpricot(thing) : nil
177
+ end
178
+
179
+ end
180
+ end
@@ -0,0 +1,199 @@
1
+ module Adapter
2
+ module Nokogiri
3
+
4
+ # Merge CSS into the HTML document.
5
+ #
6
+ # Returns a string.
7
+ def to_inline_css
8
+ doc = @processed_doc
9
+ @unmergable_rules = CssParser::Parser.new
10
+
11
+ # Give all styles already in style attributes a specificity of 1000
12
+ # per http://www.w3.org/TR/CSS21/cascade.html#specificity
13
+ doc.search("*[@style]").each do |el|
14
+ el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]'
15
+ end
16
+
17
+ # Iterate through the rules and merge them into the HTML
18
+ @css_parser.each_selector(:all) do |selector, declaration, specificity|
19
+ # Save un-mergable rules separately
20
+ selector.gsub!(/:link([\s]*)+/i) {|m| $1 }
21
+
22
+ # Convert element names to lower case
23
+ selector.gsub!(/([\s]|^)([\w]+)/) {|m| $1.to_s + $2.to_s.downcase }
24
+
25
+ if selector =~ Premailer::RE_UNMERGABLE_SELECTORS
26
+ @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration)) unless @options[:preserve_styles]
27
+ else
28
+ begin
29
+ # Change single ID CSS selectors into xpath so that we can match more
30
+ # than one element. Added to work around dodgy generated code.
31
+ selector.gsub!(/\A\#([\w_\-]+)\Z/, '*[@id=\1]')
32
+
33
+ doc.search(selector).each do |el|
34
+ if el.elem? and (el.name != 'head' and el.parent.name != 'head')
35
+ # Add a style attribute or append to the existing one
36
+ block = "[SPEC=#{specificity}[#{declaration}]]"
37
+ el['style'] = (el.attributes['style'].to_s ||= '') + ' ' + block
38
+ end
39
+ end
40
+ rescue ::Nokogiri::SyntaxError, RuntimeError, ArgumentError
41
+ $stderr.puts "CSS syntax error with selector: #{selector}" if @options[:verbose]
42
+ next
43
+ end
44
+ end
45
+ end
46
+
47
+ # Read STYLE attributes and perform folding
48
+ doc.search("*[@style]").each do |el|
49
+ style = el.attributes['style'].to_s
50
+
51
+ declarations = []
52
+
53
+ style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration|
54
+ rs = CssParser::RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i)
55
+ declarations << rs
56
+ end
57
+
58
+ # Perform style folding
59
+ merged = CssParser.merge(declarations)
60
+ merged.expand_shorthand!
61
+
62
+ # Duplicate CSS attributes as HTML attributes
63
+ if Premailer::RELATED_ATTRIBUTES.has_key?(el.name)
64
+ Premailer::RELATED_ATTRIBUTES[el.name].each do |css_att, html_att|
65
+ el[html_att] = merged[css_att].gsub(/;$/, '').strip if el[html_att].nil? and not merged[css_att].empty?
66
+ end
67
+ end
68
+
69
+ merged.create_dimensions_shorthand!
70
+
71
+ # write the inline STYLE attribute
72
+ el['style'] = Premailer.escape_string(merged.declarations_to_s)
73
+ end
74
+
75
+ doc = write_unmergable_css_rules(doc, @unmergable_rules)
76
+
77
+ if @options[:remove_classes] or @options[:remove_comments]
78
+ doc.traverse do |el|
79
+ if el.comment? and @options[:remove_comments]
80
+ el.remove
81
+ elsif el.element?
82
+ el.remove_attribute('class') if @options[:remove_classes]
83
+ end
84
+ end
85
+ end
86
+
87
+ if @options[:remove_ids]
88
+ # find all anchor's targets and hash them
89
+ targets = []
90
+ doc.search("a[@href^='#']").each do |el|
91
+ target = el.get_attribute('href')[1..-1]
92
+ targets << target
93
+ el.set_attribute('href', "#" + Digest::MD5.hexdigest(target))
94
+ end
95
+ # hash ids that are links target, delete others
96
+ doc.search("*[@id]").each do |el|
97
+ id = el.get_attribute('id')
98
+ if targets.include?(id)
99
+ el.set_attribute('id', Digest::MD5.hexdigest(id))
100
+ else
101
+ el.remove_attribute('id')
102
+ end
103
+ end
104
+ end
105
+
106
+ @processed_doc = doc
107
+ if is_xhtml?
108
+ @processed_doc.to_xhtml
109
+ else
110
+ @processed_doc.to_html
111
+ end
112
+ end
113
+
114
+ # Create a <tt>style</tt> element with un-mergable rules (e.g. <tt>:hover</tt>)
115
+ # and write it into the <tt>body</tt>.
116
+ #
117
+ # <tt>doc</tt> is an Nokogiri document and <tt>unmergable_css_rules</tt> is a Css::RuleSet.
118
+ #
119
+ # Returns an Nokogiri document.
120
+ def write_unmergable_css_rules(doc, unmergable_rules) # :nodoc:
121
+ if head = doc.at('head')
122
+ styles = ''
123
+ unmergable_rules.each_selector(:all, :force_important => true) do |selector, declarations, specificity|
124
+ styles += "#{selector} { #{declarations} }\n"
125
+ end
126
+
127
+ unless styles.empty?
128
+ style_tag = "\n<style type=\"text/css\">\n#{styles}</style>\n"
129
+
130
+ head.add_child(style_tag)
131
+ end
132
+ else
133
+ $stderr.puts "Unable to write unmergable CSS rules: no <head> was found" if @options[:verbose]
134
+ end
135
+ doc
136
+ end
137
+
138
+
139
+ # Converts the HTML document to a format suitable for plain-text e-mail.
140
+ #
141
+ # If present, uses the <body> element as its base; otherwise uses the whole document.
142
+ #
143
+ # Returns a string.
144
+ def to_plain_text
145
+ html_src = ''
146
+ begin
147
+ html_src = @doc.at("body").inner_html
148
+ rescue; end
149
+
150
+ html_src = @doc.to_html unless html_src and not html_src.empty?
151
+ convert_to_text(html_src, @options[:line_length], @html_encoding)
152
+ end
153
+
154
+ # Returns the original HTML as a string.
155
+ def to_s
156
+ if is_xhtml?
157
+ @doc.to_xhtml
158
+ else
159
+ @doc.to_html
160
+ end
161
+ end
162
+
163
+ # Load the HTML file and convert it into an Nokogiri document.
164
+ #
165
+ # Returns an Nokogiri document.
166
+ def load_html(input) # :nodoc:
167
+ thing = nil
168
+
169
+ # TODO: duplicate options
170
+ if @options[:with_html_string] or @options[:inline] or input.respond_to?(:read)
171
+ thing = input
172
+ elsif @is_local_file
173
+ @base_dir = File.dirname(input)
174
+ thing = File.open(input, 'r')
175
+ else
176
+ thing = open(input)
177
+ end
178
+
179
+ if thing.respond_to?(:read)
180
+ thing = thing.read
181
+ end
182
+
183
+ return nil unless thing
184
+
185
+ doc = nil
186
+
187
+ # Default encoding is ASCII-8BIT (binary) per http://groups.google.com/group/nokogiri-talk/msg/0b81ef0dc180dc74
188
+ if thing.is_a?(String) and RUBY_VERSION =~ /1.9/
189
+ thing = thing.force_encoding('ASCII-8BIT').encode!
190
+ doc = ::Nokogiri::HTML(thing) {|c| c.noent.recover }
191
+ else
192
+ doc = ::Nokogiri::HTML(thing, nil, 'ASCII-8BIT') {|c| c.noent.recover }
193
+ end
194
+
195
+ return doc
196
+ end
197
+
198
+ end
199
+ end
@@ -6,15 +6,10 @@ module HtmlToPlainText
6
6
 
7
7
  # Returns the text in UTF-8 format with all HTML tags removed
8
8
  #
9
- # TODO:
10
- # - add support for DL, OL
9
+ # TODO: add support for DL, OL
11
10
  def convert_to_text(html, line_length = 65, from_charset = 'UTF-8')
12
- #r = Text::Reform.new(:trim => true,
13
- # :squeeze => false,
14
- # :break => Text::Reform.break_wrap)
15
-
16
11
  txt = html
17
-
12
+
18
13
  # decode HTML entities
19
14
  he = HTMLEntities.new
20
15
  txt = he.decode(txt)
@@ -24,7 +19,7 @@ module HtmlToPlainText
24
19
  txt.gsub!(/[\s]*<h([1-6]+)[^>]*>[\s]*(.*)[\s]*<\/h[1-6]+>/i) do |s|
25
20
  hlevel = $1.to_i
26
21
 
27
- htext = $2
22
+ htext = $2
28
23
  htext.gsub!(/<br[\s]*\/?>/i, "\n") # handle <br>s
29
24
  htext.gsub!(/<\/?[^>]*>/i, '') # strip tags
30
25
 
@@ -41,10 +36,13 @@ module HtmlToPlainText
41
36
  else # H3-H6, dashes below
42
37
  htext = htext + "\n" + ('-' * hlength)
43
38
  end
44
-
39
+
45
40
  "\n\n" + htext + "\n\n"
46
41
  end
47
42
 
43
+ # wrap spans
44
+ txt.gsub!(/(<\/span>)[\s]+(<span)/mi, '\1 \2')
45
+
48
46
  # links
49
47
  txt.gsub!(/<a.*href=\"([^\"]*)\"[^>]*>(.*)<\/a>/i) do |s|
50
48
  $2.strip + ' ( ' + $1.strip + ' )'
@@ -54,20 +52,19 @@ module HtmlToPlainText
54
52
  txt.gsub!(/[\s]*(<li[^>]*>)[\s]*/i, '* ')
55
53
  # list not followed by a newline
56
54
  txt.gsub!(/<\/li>[\s]*(?![\n])/i, "\n")
57
-
55
+
58
56
  # paragraphs and line breaks
59
57
  txt.gsub!(/<\/p>/i, "\n\n")
60
58
  txt.gsub!(/<br[\/ ]*>/i, "\n")
61
-
59
+
62
60
  # strip remaining tags
63
61
  txt.gsub!(/<\/?[^>]*>/, '')
64
62
 
65
- # wrap text
66
- #txt = r.format(('[' * line_length), txt)
67
-
63
+ txt = word_wrap(txt, line_length)
64
+
68
65
  # remove linefeeds (\r\n and \r -> \n)
69
66
  txt.gsub!(/\r\n?/, "\n")
70
-
67
+
71
68
  # strip extra spaces
72
69
  txt.gsub!(/\302\240+/, " ") # non-breaking spaces -> spaces
73
70
  txt.gsub!(/\n[ \t]+/, "\n") # space at start of lines
@@ -78,4 +75,11 @@ module HtmlToPlainText
78
75
 
79
76
  txt.strip
80
77
  end
78
+
79
+ # Taken from Rails' word_wrap helper (http://api.rubyonrails.org/classes/ActionView/Helpers/TextHelper.html#method-i-word_wrap)
80
+ def word_wrap(txt, line_length)
81
+ txt.split("\n").collect do |line|
82
+ line.length > line_length ? line.gsub(/(.{1,#{line_length}})(\s+|$)/, "\\1\n").strip : line
83
+ end * "\n"
84
+ end
81
85
  end
@@ -4,8 +4,8 @@
4
4
  #
5
5
  # Premailer processes HTML and CSS to improve e-mail deliverability.
6
6
  #
7
- # Premailer's main function is to render all CSS as inline <tt>style</tt>
8
- # attributes. It also converts relative links to absolute links and checks
7
+ # Premailer's main function is to render all CSS as inline <tt>style</tt>
8
+ # attributes. It also converts relative links to absolute links and checks
9
9
  # the 'safety' of CSS properties against a CSS support chart.
10
10
  #
11
11
  # = Example
@@ -33,17 +33,17 @@ class Premailer
33
33
  include HtmlToPlainText
34
34
  include CssParser
35
35
 
36
- VERSION = '1.6.2'
36
+ VERSION = '1.7.0'
37
37
 
38
38
  CLIENT_SUPPORT_FILE = File.dirname(__FILE__) + '/../../misc/client_support.yaml'
39
39
 
40
40
  RE_UNMERGABLE_SELECTORS = /(\:(visited|active|hover|focus|after|before|selection|target|first\-(line|letter))|^\@)/i
41
-
41
+
42
42
  # list of CSS attributes that can be rendered as HTML attributes
43
43
  #
44
44
  # TODO: too much repetition
45
45
  # TODO: background=""
46
- RELATED_ATTRIBUTES = {
46
+ RELATED_ATTRIBUTES = {
47
47
  'h1' => {'text-align' => 'align'},
48
48
  'h2' => {'text-align' => 'align'},
49
49
  'h3' => {'text-align' => 'align'},
@@ -63,18 +63,20 @@ class Premailer
63
63
 
64
64
  # URI of the HTML file used
65
65
  attr_reader :html_file
66
-
66
+
67
67
  # base URL used to resolve links
68
68
  attr_reader :base_url
69
69
 
70
70
  # base directory used to resolve links for local files
71
71
  attr_reader :base_dir
72
72
 
73
-
74
- # processed HTML document (Hpricot)
73
+ # unmergeable CSS rules to be preserved in the head (CssParser)
74
+ attr_reader :unmergable_rules
75
+
76
+ # processed HTML document (Hpricot/Nokogiri)
75
77
  attr_reader :processed_doc
76
-
77
- # source HTML document (Hpricot)
78
+
79
+ # source HTML document (Hpricot/Nokogiri)
78
80
  attr_reader :doc
79
81
 
80
82
  module Warnings
@@ -89,8 +91,8 @@ class Premailer
89
91
 
90
92
  # Create a new Premailer object.
91
93
  #
92
- # +html+ is the HTML data to process. It can be either an IO object, the URL of a
93
- # remote file, a local path or a raw HTML string. If passing an HTML string you
94
+ # +html+ is the HTML data to process. It can be either an IO object, the URL of a
95
+ # remote file, a local path or a raw HTML string. If passing an HTML string you
94
96
  # must set the +:with_html_string+ option to +true+.
95
97
  #
96
98
  # ==== Options
@@ -100,24 +102,33 @@ class Premailer
100
102
  # [+base_url+] Used to calculate absolute URLs for local files.
101
103
  # [+css+] Manually specify a CSS stylesheet.
102
104
  # [+css_to_attributes+] Copy related CSS attributes into HTML attributes (e.g. +background-color+ to +bgcolor+)
105
+ # [+css_string+] Pass CSS as a string
106
+ # [+remove_ids+] Remove ID attributes whenever possible and convert IDs used as anchors to hashed to avoid collisions in webmail programs. Default is +false+.
107
+ # [+remove_classes+] Remove class attributes. Default is +false+.
108
+ # [+remove_comments+] Remove html comments. Default is +false+.
103
109
  # [+preserve_styles+] Whether to preserve any <tt>link rel=stylesheet</tt> and <tt>style</tt> elements. Default is +false+.
104
110
  # [+with_html_string+] Whether the +html+ param should be treated as a raw string.
105
111
  # [+verbose+] Whether to print errors and warnings to <tt>$stderr</tt>. Default is +false+.
112
+ # [+adapter+] Which HTML parser to use, either <tt>:nokogiri</tt> or <tt>:hpricot</tt>. Default is <tt>:hpricot</tt>.
106
113
  def initialize(html, options = {})
107
- @options = {:warn_level => Warnings::SAFE,
108
- :line_length => 65,
109
- :link_query_string => nil,
114
+ @options = {:warn_level => Warnings::SAFE,
115
+ :line_length => 65,
116
+ :link_query_string => nil,
110
117
  :base_url => nil,
111
118
  :remove_classes => false,
119
+ :remove_ids => false,
120
+ :remove_comments => false,
112
121
  :css => [],
113
122
  :css_to_attributes => true,
114
123
  :with_html_string => false,
124
+ :css_string => nil,
115
125
  :preserve_styles => false,
116
126
  :verbose => false,
117
127
  :debug => false,
118
- :io_exceptions => false}.merge(options)
128
+ :io_exceptions => false,
129
+ :adapter => Adapter.use}.merge(options)
119
130
 
120
- @html_file = html
131
+ @html_file = html
121
132
  @is_local_file = @options[:with_html_string] || Premailer.local_data?(html)
122
133
 
123
134
  @css_files = @options[:css]
@@ -126,6 +137,7 @@ class Premailer
126
137
 
127
138
  @base_url = nil
128
139
  @base_dir = nil
140
+ @unmergable_rules = nil
129
141
 
130
142
  if @options[:base_url]
131
143
  @base_url = URI.parse(@options.delete(:base_url))
@@ -138,10 +150,14 @@ class Premailer
138
150
  :import => true,
139
151
  :io_exceptions => @options[:io_exceptions]
140
152
  })
141
-
153
+
154
+ @adapter_name = @options[:adapter]
155
+ @adapter_name, @adapter_class = Adapter.find @adapter_name
156
+
157
+ self.class.send(:include, @adapter_class)
158
+
142
159
  @doc = load_html(@html_file)
143
- # TODO
144
- @html_charset = nil # @doc.encoding || nil
160
+
145
161
  @processed_doc = @doc
146
162
  @processed_doc = convert_inline_links(@processed_doc, @base_url) if @base_url
147
163
  if options[:link_query_string]
@@ -157,141 +173,8 @@ class Premailer
157
173
  @css_warnings = check_client_support if @css_warnings.empty?
158
174
  @css_warnings
159
175
  end
160
-
161
- # Returns the original HTML as a string.
162
- def to_s
163
- @doc.to_original_html
164
- end
165
-
166
- # Converts the HTML document to a format suitable for plain-text e-mail.
167
- #
168
- # Returns a string.
169
- def to_plain_text
170
- html_src = ''
171
- begin
172
- html_src = @doc.search("body").inner_html
173
- rescue
174
- html_src = @doc.to_html
175
- end
176
- convert_to_text(html_src, @options[:line_length], @html_charset)
177
- end
178
-
179
- # Merge CSS into the HTML document.
180
- #
181
- # Returns a string.
182
- def to_inline_css
183
- doc = @processed_doc
184
- unmergable_rules = CssParser::Parser.new
185
-
186
- # Give all styles already in style attributes a specificity of 1000
187
- # per http://www.w3.org/TR/CSS21/cascade.html#specificity
188
- doc.search("*[@style]").each do |el|
189
- el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]'
190
- end
191
-
192
- # Iterate through the rules and merge them into the HTML
193
- @css_parser.each_selector(:all) do |selector, declaration, specificity|
194
- # Save un-mergable rules separately
195
- selector.gsub!(/:link([\s]*)+/i) {|m| $1 }
196
-
197
- # Convert element names to lower case
198
- selector.gsub!(/([\s]|^)([\w]+)/) {|m| $1.to_s + $2.to_s.downcase }
199
-
200
- if selector =~ RE_UNMERGABLE_SELECTORS
201
- unmergable_rules.add_rule_set!(RuleSet.new(selector, declaration)) unless @options[:preserve_styles]
202
- else
203
- begin
204
- doc.search(selector).each do |el|
205
- if el.elem? and (el.name != 'head' and el.parent.name != 'head')
206
- # Add a style attribute or append to the existing one
207
- block = "[SPEC=#{specificity}[#{declaration}]]"
208
- el['style'] = (el.attributes['style'].to_s ||= '') + ' ' + block
209
- end
210
- end
211
- rescue Hpricot::Error, RuntimeError, ArgumentError
212
- $stderr.puts "CSS syntax error with selector: #{selector}" if @options[:verbose]
213
- next
214
- end
215
- end
216
- end
217
-
218
- # Read STYLE attributes and perform folding
219
- doc.search("*[@style]").each do |el|
220
- style = el.attributes['style'].to_s
221
-
222
- declarations = []
223
-
224
- style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration|
225
- rs = RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i)
226
- declarations << rs
227
- end
228
-
229
- # Perform style folding
230
- merged = CssParser.merge(declarations)
231
- merged.expand_shorthand!
232
-
233
- #if @options[:prefer_cellpadding] and (el.name == 'td' or el.name == 'th') and el['cellpadding'].nil?
234
- # if cellpadding = equivalent_cellpadding(merged)
235
- # el['cellpadding'] = cellpadding
236
- # merged['padding-left'] = nil
237
- # merged['padding-right'] = nil
238
- # merged['padding-top'] = nil
239
- # merged['padding-bottom'] = nil
240
- # end
241
- #end
242
-
243
- # Duplicate CSS attributes as HTML attributes
244
- if RELATED_ATTRIBUTES.has_key?(el.name)
245
- RELATED_ATTRIBUTES[el.name].each do |css_att, html_att|
246
- el[html_att] = merged[css_att].gsub(/;$/, '').strip if el[html_att].nil? and not merged[css_att].empty?
247
- end
248
- end
249
-
250
- merged.create_dimensions_shorthand!
251
-
252
- # write the inline STYLE attribute
253
- el['style'] = Premailer.escape_string(merged.declarations_to_s)
254
- end
255
-
256
- doc = write_unmergable_css_rules(doc, unmergable_rules)
257
-
258
- doc.search('*').remove_class if @options[:remove_classes]
259
-
260
- @processed_doc = doc
261
-
262
- @processed_doc.to_original_html
263
- end
264
-
265
- # Check for an XHTML doctype
266
- def is_xhtml?
267
- intro = @doc.to_s.strip.split("\n")[0..2].join(' ')
268
- is_xhtml = (intro =~ /w3c\/\/[\s]*dtd[\s]+xhtml/i)
269
- is_xhtml = is_xhtml ? true : false
270
- $stderr.puts "Is XHTML? #{is_xhtml.inspect}\nChecked:\n#{intro}" if @options[:debug]
271
- is_xhtml
272
- end
273
-
274
- protected
275
- # Load the HTML file and convert it into an Hpricot document.
276
- #
277
- # Returns an Hpricot document.
278
- def load_html(input) # :nodoc:
279
- thing = nil
280
-
281
- # TODO: duplicate options
282
- if @options[:with_html_string] or @options[:inline] or input.respond_to?(:read)
283
- thing = input
284
- elsif @is_local_file
285
- @base_dir = File.dirname(input)
286
- thing = File.open(input, 'r')
287
- else
288
- thing = open(input)
289
- end
290
-
291
- # TODO: deal with Hpricot seg faults on empty input
292
- thing ? Hpricot(thing) : nil
293
- end
294
176
 
177
+ protected
295
178
  def load_css_from_local_file!(path)
296
179
  css_block = ''
297
180
  begin
@@ -300,11 +183,18 @@ protected
300
183
  css_block << line
301
184
  end
302
185
  end
303
- @css_parser.add_block!(css_block, {:base_uri => @base_url, :base_dir => @base_dir})
186
+
187
+ load_css_from_string(css_block)
304
188
  rescue; end
305
189
  end
306
190
 
191
+ def load_css_from_string(css_string)
192
+ @css_parser.add_block!(css_string, {:base_uri => @base_url, :base_dir => @base_dir, :only_media_types => [:screen, :handheld]})
193
+ end
194
+
307
195
  def load_css_from_options! # :nodoc:
196
+ load_css_from_string(@options[:css_string]) if @options[:css_string]
197
+
308
198
  @css_files.each do |css_file|
309
199
  if Premailer.local_data?(css_file)
310
200
  load_css_from_local_file!(css_file)
@@ -314,7 +204,7 @@ protected
314
204
  end
315
205
  end
316
206
 
317
- # Load CSS included in <tt>style</tt> and <tt>link</tt> tags from an HTML document.
207
+ # Load CSS included in <tt>style</tt> and <tt>link</tt> tags from an HTML document.
318
208
  def load_css_from_html! # :nodoc:
319
209
  if tags = @doc.search("link[@rel='stylesheet'], style")
320
210
  tags.each do |tag|
@@ -326,10 +216,10 @@ protected
326
216
  load_css_from_local_file!(link_uri)
327
217
  else
328
218
  $stderr.puts "Loading css from uri: " + link_uri if @options[:verbose]
329
- @css_parser.load_uri!(link_uri)
219
+ @css_parser.load_uri!(link_uri, {:only_media_types => [:screen, :handheld]})
330
220
  end
331
221
 
332
- elsif tag.to_s.strip =~ /^\<style/i
222
+ elsif tag.to_s.strip =~ /^\<style/i
333
223
  @css_parser.add_block!(tag.inner_html, :base_uri => @base_url, :base_dir => @base_dir, :only_media_types => [:screen, :handheld])
334
224
  end
335
225
  end
@@ -337,6 +227,17 @@ protected
337
227
  end
338
228
  end
339
229
 
230
+
231
+
232
+ # here be deprecated methods
233
+ public
234
+ def local_uri?(uri) # :nodoc:
235
+ warn "[DEPRECATION] `local_uri?` is deprecated. Please use `Premailer.local_data?` instead."
236
+ Premailer.local_data?(uri)
237
+ end
238
+
239
+ # here be instance methods
240
+
340
241
  def media_type_ok?(media_types) # :nodoc:
341
242
  return true if media_types.nil? or media_types.empty?
342
243
  return media_types.split(/[\s]+|,/).any? { |media_type| media_type.strip =~ /screen|handheld|all/i }
@@ -344,35 +245,12 @@ protected
344
245
  return true
345
246
  end
346
247
 
347
- # Create a <tt>style</tt> element with un-mergable rules (e.g. <tt>:hover</tt>)
348
- # and write it into the <tt>body</tt>.
349
- #
350
- # <tt>doc</tt> is an Hpricot document and <tt>unmergable_css_rules</tt> is a Css::RuleSet.
351
- #
352
- # Returns an Hpricot document.
353
- def write_unmergable_css_rules(doc, unmergable_rules) # :nodoc:
354
- if head = doc.search('head')
355
- styles = ''
356
- unmergable_rules.each_selector(:all, :force_important => true) do |selector, declarations, specificity|
357
- styles += "#{selector} { #{declarations} }\n"
358
- end
359
-
360
- unless styles.empty?
361
- style_tag = "\n<style type=\"text/css\">\n#{styles}</style>\n"
362
- head.html.empty? ? head.inner_html(style_tag) : head.append(style_tag)
363
- end
364
- else
365
- $stderr.puts "Unable to write unmergable CSS rules: no <head> was found" if @options[:verbose]
366
- end
367
- doc
368
- end
369
-
370
248
  def append_query_string(doc, qs)
371
249
  return doc if qs.nil?
372
250
 
373
251
  qs.to_s.gsub!(/^[\?]*/, '').strip!
374
252
  return doc if qs.empty?
375
-
253
+
376
254
  begin
377
255
  current_host = @base_url.host
378
256
  rescue
@@ -380,10 +258,10 @@ protected
380
258
  end
381
259
 
382
260
  $stderr.puts "Attempting to append_query_string: #{qs}" if @options[:verbose]
383
-
261
+
384
262
  doc.search('a').each do|el|
385
263
  href = el.attributes['href'].to_s.strip
386
- next if href.nil? or href.empty?
264
+ next if href.nil? or href.empty?
387
265
  next if href[0,1] == '#' # don't bother with anchors
388
266
 
389
267
  begin
@@ -391,7 +269,7 @@ protected
391
269
 
392
270
  if current_host and href.host != nil and href.host != current_host
393
271
  $stderr.puts "Skipping append_query_string for: #{href.to_s} because host is no good" if @options[:verbose]
394
- next
272
+ next
395
273
  end
396
274
 
397
275
  if href.scheme and href.scheme != 'http' and href.scheme != 'https'
@@ -404,7 +282,7 @@ protected
404
282
  else
405
283
  href.query = qs
406
284
  end
407
-
285
+
408
286
  el['href'] = href.to_s
409
287
  rescue URI::Error => e
410
288
  $stderr.puts "Skipping append_query_string for: #{href.to_s} (#{e.message})" if @options[:verbose]
@@ -415,9 +293,18 @@ protected
415
293
  doc
416
294
  end
417
295
 
418
- # Convert relative links to absolute links.
296
+ # Check for an XHTML doctype
297
+ def is_xhtml?
298
+ intro = @doc.to_s.strip.split("\n")[0..2].join(' ')
299
+ is_xhtml = (intro =~ /w3c\/\/[\s]*dtd[\s]+xhtml/i)
300
+ is_xhtml = is_xhtml ? true : false
301
+ $stderr.puts "Is XHTML? #{is_xhtml.inspect}\nChecked:\n#{intro}" if @options[:debug]
302
+ is_xhtml
303
+ end
304
+
305
+ # Convert relative links to absolute links.
419
306
  #
420
- # Processes <tt>href</tt> <tt>src</tt> and <tt>background</tt> attributes
307
+ # Processes <tt>href</tt> <tt>src</tt> and <tt>background</tt> attributes
421
308
  # as well as CSS <tt>url()</tt> declarations found in inline <tt>style</tt> attributes.
422
309
  #
423
310
  # <tt>doc</tt> is an Hpricot document and <tt>base_uri</tt> is either a string or a URI.
@@ -466,20 +353,11 @@ protected
466
353
  doc
467
354
  end
468
355
 
469
- # here be deprecated methods
470
- public
471
-
472
- def local_uri?(uri) # :nodoc:
473
- warn "[DEPRECATION] `local_uri?` is deprecated. Please use `Premailer.local_data?` instead."
474
- Premailer.local_data?(uri)
475
- end
476
-
477
- # here be instance methods
478
356
 
479
357
  def self.escape_string(str) # :nodoc:
480
- str.gsub(/"/, "'")
358
+ str.gsub(/"/ , "'")
481
359
  end
482
-
360
+
483
361
  def self.resolve_link(path, base_path) # :nodoc:
484
362
  path.strip!
485
363
  resolved = nil
@@ -488,7 +366,7 @@ public
488
366
  return Premailer.canonicalize(resolved)
489
367
  elsif base_path.kind_of?(URI)
490
368
  resolved = base_path.merge(path)
491
- return Premailer.canonicalize(resolved)
369
+ return Premailer.canonicalize(resolved)
492
370
  elsif base_path.kind_of?(String) and base_path =~ /^(http[s]?|ftp):\/\//i
493
371
  resolved = URI.parse(base_path)
494
372
  resolved = resolved.merge(path)
@@ -510,7 +388,7 @@ public
510
388
  else
511
389
  return true
512
390
  end
513
- end
391
+ end
514
392
 
515
393
  # from http://www.ruby-forum.com/topic/140101
516
394
  def self.canonicalize(uri) # :nodoc:
@@ -525,54 +403,51 @@ public
525
403
  u.to_s
526
404
  end
527
405
 
528
- # Check <tt>CLIENT_SUPPORT_FILE</tt> for any CSS warnings
529
- def check_client_support # :nodoc:
530
- @client_support = @client_support ||= YAML::load(File.open(CLIENT_SUPPORT_FILE))
531
-
532
- warnings = []
533
- properties = []
534
-
535
- # Get a list off CSS properties
536
- @processed_doc.search("*[@style]").each do |el|
537
- style_url = el.attributes['style'].to_s.gsub(/([\w\-]+)[\s]*\:/i) do |s|
538
- properties.push($1)
539
- end
540
- end
406
+ # Check <tt>CLIENT_SUPPORT_FILE</tt> for any CSS warnings
407
+ def check_client_support # :nodoc:
408
+ @client_support = @client_support ||= YAML::load(File.open(CLIENT_SUPPORT_FILE))
541
409
 
542
- properties.uniq!
410
+ warnings = []
411
+ properties = []
543
412
 
544
- property_support = @client_support['css_properties']
545
- properties.each do |prop|
546
- if property_support.include?(prop) and
547
- property_support[prop].include?('support') and
548
- property_support[prop]['support'] >= @options[:warn_level]
549
- warnings.push({:message => "#{prop} CSS property",
550
- :level => WARN_LABEL[property_support[prop]['support']],
551
- :clients => property_support[prop]['unsupported_in'].join(', ')})
552
- end
413
+ # Get a list off CSS properties
414
+ @processed_doc.search("*[@style]").each do |el|
415
+ style_url = el.attributes['style'].to_s.gsub(/([\w\-]+)[\s]*\:/i) do |s|
416
+ properties.push($1)
553
417
  end
418
+ end
554
419
 
555
- @client_support['attributes'].each do |attribute, data|
556
- next unless data['support'] >= @options[:warn_level]
557
- if @doc.search("*[@#{attribute}]").length > 0
558
- warnings.push({:message => "#{attribute} HTML attribute",
559
- :level => WARN_LABEL[property_support[prop]['support']],
560
- :clients => property_support[prop]['unsupported_in'].join(', ')})
561
- end
562
- end
420
+ properties.uniq!
563
421
 
564
- @client_support['elements'].each do |element, data|
565
- next unless data['support'] >= @options[:warn_level]
566
- if @doc.search("element").length > 0
567
- warnings.push({:message => "#{element} HTML element",
568
- :level => WARN_LABEL[property_support[prop]['support']],
569
- :clients => property_support[prop]['unsupported_in'].join(', ')})
570
- end
422
+ property_support = @client_support['css_properties']
423
+ properties.each do |prop|
424
+ if property_support.include?(prop) and
425
+ property_support[prop].include?('support') and
426
+ property_support[prop]['support'] >= @options[:warn_level]
427
+ warnings.push({:message => "#{prop} CSS property",
428
+ :level => WARN_LABEL[property_support[prop]['support']],
429
+ :clients => property_support[prop]['unsupported_in'].join(', ')})
571
430
  end
572
-
573
- return warnings
574
431
  end
575
- end
576
432
 
433
+ @client_support['attributes'].each do |attribute, data|
434
+ next unless data['support'] >= @options[:warn_level]
435
+ if @doc.search("*[@#{attribute}]").length > 0
436
+ warnings.push({:message => "#{attribute} HTML attribute",
437
+ :level => WARN_LABEL[data['support']],
438
+ :clients => data['unsupported_in'].join(', ')})
439
+ end
440
+ end
577
441
 
442
+ @client_support['elements'].each do |element, data|
443
+ next unless data['support'] >= @options[:warn_level]
444
+ if @doc.search(element).length > 0
445
+ warnings.push({:message => "#{element} HTML element",
446
+ :level => WARN_LABEL[data['support']],
447
+ :clients => data['unsupported_in'].join(', ')})
448
+ end
449
+ end
578
450
 
451
+ return warnings
452
+ end
453
+ end
metadata CHANGED
@@ -4,9 +4,9 @@ version: !ruby/object:Gem::Version
4
4
  prerelease: false
5
5
  segments:
6
6
  - 1
7
- - 6
8
- - 2
9
- version: 1.6.2
7
+ - 7
8
+ - 0
9
+ version: 1.7.0
10
10
  platform: ruby
11
11
  authors:
12
12
  - Alex Dunae
@@ -14,7 +14,7 @@ autorequire:
14
14
  bindir: bin
15
15
  cert_chain: []
16
16
 
17
- date: 2010-11-22 00:00:00 -08:00
17
+ date: 2011-12-31 00:00:00 -08:00
18
18
  default_executable:
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
@@ -43,8 +43,8 @@ dependencies:
43
43
  segments:
44
44
  - 1
45
45
  - 1
46
- - 3
47
- version: 1.1.3
46
+ - 6
47
+ version: 1.1.6
48
48
  type: :runtime
49
49
  version_requirements: *id002
50
50
  - !ruby/object:Gem::Dependency
@@ -76,6 +76,9 @@ files:
76
76
  - lib/premailer.rb
77
77
  - lib/premailer/html_to_plain_text.rb
78
78
  - lib/premailer/premailer.rb
79
+ - lib/premailer/adapter.rb
80
+ - lib/premailer/adapter/hpricot.rb
81
+ - lib/premailer/adapter/nokogiri.rb
79
82
  - misc/client_support.yaml
80
83
  - README.rdoc
81
84
  has_rdoc: true