premailer 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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