css_parser_master 1.2.4
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/CHANGELOG +34 -0
- data/LICENSE +21 -0
- data/README +0 -0
- data/README.rdoc +77 -0
- data/Rakefile.rb +18 -0
- data/VERSION +1 -0
- data/lib/css_parser_master.rb +156 -0
- data/lib/css_parser_master/declaration.rb +31 -0
- data/lib/css_parser_master/declaration_api.rb +91 -0
- data/lib/css_parser_master/declarations.rb +23 -0
- data/lib/css_parser_master/parser.rb +388 -0
- data/lib/css_parser_master/regexps.rb +46 -0
- data/lib/css_parser_master/rule_set.rb +337 -0
- data/lib/css_parser_master/selector.rb +33 -0
- data/lib/css_parser_master/selectors.rb +27 -0
- data/test/fixtures/import-circular-reference.css +4 -0
- data/test/fixtures/import-with-media-types.css +3 -0
- data/test/fixtures/import1.css +3 -0
- data/test/fixtures/simple.css +6 -0
- data/test/fixtures/subdir/import2.css +3 -0
- data/test/test_css_parser_basic.rb +84 -0
- data/test/test_css_parser_loading.rb +110 -0
- data/test/test_css_parser_media_types.rb +71 -0
- data/test/test_css_parser_misc.rb +151 -0
- data/test/test_css_parser_regexps.rb +69 -0
- data/test/test_helper.rb +6 -0
- data/test/test_merging.rb +88 -0
- data/test/test_rule_set.rb +90 -0
- data/test/test_rule_set_creating_shorthand.rb +90 -0
- data/test/test_rule_set_expanding_shorthand.rb +179 -0
- data/test/test_ruleset_expand.rb +40 -0
- data/test/test_selector.rb +26 -0
- data/test/test_selector_parsing.rb +27 -0
- metadata +112 -0
@@ -0,0 +1,388 @@
|
|
1
|
+
module CssParserMaster
|
2
|
+
# Exception class used for any errors encountered while downloading remote files.
|
3
|
+
class RemoteFileError < IOError; end
|
4
|
+
|
5
|
+
# Exception class used if a request is made to load a CSS file more than once.
|
6
|
+
class CircularReferenceError < StandardError; end
|
7
|
+
|
8
|
+
|
9
|
+
# == Parser class
|
10
|
+
#
|
11
|
+
# All CSS is converted to UTF-8.
|
12
|
+
#
|
13
|
+
# When calling Parser#new there are some configuaration options:
|
14
|
+
# [<tt>absolute_paths</tt>] Convert relative paths to absolute paths (<tt>href</tt>, <tt>src</tt> and <tt>url('')</tt>. Boolean, default is <tt>false</tt>.
|
15
|
+
# [<tt>import</tt>] Follow <tt>@import</tt> rules. Boolean, default is <tt>true</tt>.
|
16
|
+
# [<tt>io_exceptions</tt>] Throw an exception if a link can not be found. Boolean, default is <tt>true</tt>.
|
17
|
+
class Parser
|
18
|
+
USER_AGENT = "Ruby CSS Parser/#{CssParserMaster::VERSION} (http://code.dunae.ca/css_parser/)"
|
19
|
+
|
20
|
+
STRIP_CSS_COMMENTS_RX = /\/\*.*?\*\//m
|
21
|
+
STRIP_HTML_COMMENTS_RX = /\<\!\-\-|\-\-\>/m
|
22
|
+
|
23
|
+
# Initial parsing
|
24
|
+
RE_AT_IMPORT_RULE = /\@import[\s]+(url\()?["''"]?(.[^'"\s"']*)["''"]?\)?([\w\s\,^\])]*)\)?;?/
|
25
|
+
|
26
|
+
#--
|
27
|
+
# RE_AT_IMPORT_RULE = Regexp.new('@import[\s]*(' + RE_STRING.to_s + ')([\w\s\,]*)[;]?', Regexp::IGNORECASE) -- should handle url() even though it is not allowed
|
28
|
+
#++
|
29
|
+
|
30
|
+
# Array of CSS files that have been loaded.
|
31
|
+
attr_reader :loaded_uris
|
32
|
+
|
33
|
+
#attr_reader :rules
|
34
|
+
|
35
|
+
#--
|
36
|
+
# Class variable? see http://www.oreillynet.com/ruby/blog/2007/01/nubygems_dont_use_class_variab_1.html
|
37
|
+
#++
|
38
|
+
@folded_declaration_cache = {}
|
39
|
+
class << self; attr_reader :folded_declaration_cache; end
|
40
|
+
|
41
|
+
def initialize(options = {})
|
42
|
+
@options = {:absolute_paths => false,
|
43
|
+
:import => true,
|
44
|
+
:io_exceptions => true}.merge(options)
|
45
|
+
|
46
|
+
# array of RuleSets
|
47
|
+
@rules = []
|
48
|
+
|
49
|
+
|
50
|
+
@loaded_uris = []
|
51
|
+
|
52
|
+
# unprocessed blocks of CSS
|
53
|
+
@blocks = []
|
54
|
+
reset!
|
55
|
+
end
|
56
|
+
|
57
|
+
# Get declarations by selector.
|
58
|
+
#
|
59
|
+
# +media_types+ are optional, and can be a symbol or an array of symbols.
|
60
|
+
# The default value is <tt>:all</tt>.
|
61
|
+
#
|
62
|
+
# ==== Examples
|
63
|
+
# find_by_selector('#content')
|
64
|
+
# => 'font-size: 13px; line-height: 1.2;'
|
65
|
+
#
|
66
|
+
# find_by_selector('#content', [:screen, :handheld])
|
67
|
+
# => 'font-size: 13px; line-height: 1.2;'
|
68
|
+
#
|
69
|
+
# find_by_selector('#content', :print)
|
70
|
+
# => 'font-size: 11pt; line-height: 1.2;'
|
71
|
+
#
|
72
|
+
# Returns an array of declarations.
|
73
|
+
def find_by_selector(selector, media_types = :all)
|
74
|
+
out = []
|
75
|
+
each_selector(media_types) do |sel|
|
76
|
+
# puts "selector declaration: #{sel.declarations_to_s}"
|
77
|
+
out << sel.declarations_to_s if sel.selector.strip == selector.strip
|
78
|
+
end
|
79
|
+
out
|
80
|
+
end
|
81
|
+
alias_method :[], :find_by_selector
|
82
|
+
|
83
|
+
|
84
|
+
# Add a raw block of CSS.
|
85
|
+
#
|
86
|
+
# In order to follow +@import+ rules you must supply either a
|
87
|
+
# +:base_dir+ or +:base_uri+ option.
|
88
|
+
#
|
89
|
+
# ==== Example
|
90
|
+
# css = <<-EOT
|
91
|
+
# body { font-size: 10pt }
|
92
|
+
# p { margin: 0px; }
|
93
|
+
# @media screen, print {
|
94
|
+
# body { line-height: 1.2 }
|
95
|
+
# }
|
96
|
+
# EOT
|
97
|
+
#
|
98
|
+
# parser = CssParserMaster::Parser.new
|
99
|
+
# parser.add_block!(css)
|
100
|
+
#--
|
101
|
+
# TODO: add media_type
|
102
|
+
#++
|
103
|
+
def add_block!(block, options = {})
|
104
|
+
options = {:base_uri => nil, :base_dir => nil, :charset => nil, :media_types => :all}.merge(options)
|
105
|
+
|
106
|
+
block = cleanup_block(block)
|
107
|
+
|
108
|
+
if options[:base_uri] and @options[:absolute_paths]
|
109
|
+
block = CssParserMaster.convert_uris(block, options[:base_uri])
|
110
|
+
end
|
111
|
+
|
112
|
+
# Load @imported CSS
|
113
|
+
block.scan(RE_AT_IMPORT_RULE).each do |import_rule|
|
114
|
+
media_types = []
|
115
|
+
if media_string = import_rule[import_rule.length-1]
|
116
|
+
media_string.split(/\s|\,/).each do |t|
|
117
|
+
media_types << t.to_sym unless t.empty?
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
import_path = import_rule[1].to_s.gsub(/['"]*/, '').strip
|
122
|
+
|
123
|
+
if options[:base_uri]
|
124
|
+
import_uri = URI.parse(options[:base_uri].to_s).merge(import_path)
|
125
|
+
load_uri!(import_uri, options[:base_uri], media_types)
|
126
|
+
elsif options[:base_dir]
|
127
|
+
load_file!(import_path, options[:base_dir], media_types)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Remove @import declarations
|
132
|
+
block.gsub!(RE_AT_IMPORT_RULE, '')
|
133
|
+
parse_block_into_rule_sets!(block, options)
|
134
|
+
|
135
|
+
end
|
136
|
+
|
137
|
+
# Add a CSS rule by setting the +selectors+, +declarations+ and +media_types+.
|
138
|
+
#
|
139
|
+
# +media_types+ can be a symbol or an array of symbols.
|
140
|
+
def add_rule!(selectors, declarations, media_types = :all)
|
141
|
+
rule_set = RuleSet.new(selectors, declarations)
|
142
|
+
# rule_set = RuleSet.new(Selectors.new(selectors), declarations)
|
143
|
+
add_rule_set!(rule_set, media_types)
|
144
|
+
end
|
145
|
+
|
146
|
+
# Add a CssParserMaster RuleSet object.
|
147
|
+
#
|
148
|
+
# +media_types+ can be a symbol or an array of symbols.
|
149
|
+
def add_rule_set!(ruleset, media_types = :all)
|
150
|
+
raise ArgumentError unless ruleset.kind_of?(CssParserMaster::RuleSet)
|
151
|
+
|
152
|
+
media_types = [media_types] if media_types.kind_of?(Symbol)
|
153
|
+
|
154
|
+
@rules << {:media_types => media_types, :rules => ruleset}
|
155
|
+
end
|
156
|
+
|
157
|
+
# Iterate through RuleSet objects.
|
158
|
+
#
|
159
|
+
# +media_types+ can be a symbol or an array of symbols.
|
160
|
+
def each_rule_set(media_types = :all) # :yields: rule_set
|
161
|
+
media_types = [:all] if media_types.nil?
|
162
|
+
media_types = [media_types] if media_types.kind_of?(Symbol)
|
163
|
+
|
164
|
+
@rules.each do |block|
|
165
|
+
if media_types.include?(:all) or block[:media_types].any? { |mt| media_types.include?(mt) }
|
166
|
+
yield block[:rules]
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# go through all selector of the parser in order of specificity
|
172
|
+
# parse declarations for each selector
|
173
|
+
def selector_declarations(&block)
|
174
|
+
each_selector_sorted(:all, :order => :asc) do |sel|
|
175
|
+
sel.declarations.each do |dec|
|
176
|
+
yield sel, dec
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Iterate through CSS selectors.
|
182
|
+
#
|
183
|
+
# +media_types+ can be a symbol or an array of symbols.
|
184
|
+
# See RuleSet#each_selector for +options+.
|
185
|
+
def each_selector(media_types = :all, options = {}) # :yields: selectors, declarations, specificity
|
186
|
+
each_rule_set(media_types) do |rule_set|
|
187
|
+
#puts rule_set
|
188
|
+
rule_set.each_selector(options) do |selector|
|
189
|
+
# puts "selector: #{selector.inspect}"
|
190
|
+
yield selector
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
# Iterate through CSS selectors.
|
196
|
+
#
|
197
|
+
# +media_types+ can be a symbol or an array of symbols.
|
198
|
+
# See RuleSet#each_selector for +options+.
|
199
|
+
def each_selector_sorted(media_types = :all, options = {}) # :yields: selectors, declarations, specificity
|
200
|
+
order = options.delete(:order) || :desc
|
201
|
+
sels = []
|
202
|
+
each_selector(media_types, options) do |sel|
|
203
|
+
sels << sel
|
204
|
+
end
|
205
|
+
sorted_sels = sels.sort_by(&:specificity).reverse
|
206
|
+
sorted_sels.reverse! if order == :asc
|
207
|
+
sorted_sels.each{|s| yield s}
|
208
|
+
end
|
209
|
+
|
210
|
+
|
211
|
+
# Output all CSS rules as a single stylesheet.
|
212
|
+
def to_s(media_types = :all)
|
213
|
+
out = ''
|
214
|
+
each_selector(media_types) do |selector|
|
215
|
+
out << selector.to_s
|
216
|
+
end
|
217
|
+
out
|
218
|
+
end
|
219
|
+
|
220
|
+
# Merge declarations with the same selector.
|
221
|
+
def compact! # :nodoc:
|
222
|
+
compacted = []
|
223
|
+
|
224
|
+
compacted
|
225
|
+
end
|
226
|
+
|
227
|
+
def parse_block_into_rule_sets!(block, options = {}) # :nodoc:
|
228
|
+
options = {:media_types => :all}.merge(options)
|
229
|
+
media_types = options[:media_types]
|
230
|
+
|
231
|
+
in_declarations = false
|
232
|
+
|
233
|
+
block_depth = 0
|
234
|
+
|
235
|
+
# @charset is ignored for now
|
236
|
+
in_charset = false
|
237
|
+
in_string = false
|
238
|
+
in_at_media_rule = false
|
239
|
+
|
240
|
+
current_selectors = ''
|
241
|
+
current_declarations = ''
|
242
|
+
|
243
|
+
block.scan(/([\\]?[{}\s"]|(.[^\s"{}\\]*))/).each do |matches|
|
244
|
+
#block.scan(/((.[^{}"\n\r\f\s]*)[\s]|(.[^{}"\n\r\f]*)\{|(.[^{}"\n\r\f]*)\}|(.[^{}"\n\r\f]*)\"|(.*)[\s]+)/).each do |matches|
|
245
|
+
token = matches[0]
|
246
|
+
#puts "TOKEN: #{token}" unless token =~ /^[\s]*$/
|
247
|
+
if token =~ /\A"/ # found un-escaped double quote
|
248
|
+
in_string = !in_string
|
249
|
+
end
|
250
|
+
|
251
|
+
if in_declarations
|
252
|
+
current_declarations += token
|
253
|
+
|
254
|
+
if token =~ /\}/ and not in_string
|
255
|
+
current_declarations.gsub!(/\}[\s]*$/, '')
|
256
|
+
|
257
|
+
in_declarations = false
|
258
|
+
|
259
|
+
unless current_declarations.strip.empty?
|
260
|
+
#puts "SAVING #{current_selectors} -> #{current_declarations}"
|
261
|
+
add_rule!(current_selectors, current_declarations, media_types)
|
262
|
+
end
|
263
|
+
|
264
|
+
current_selectors = ''
|
265
|
+
current_declarations = ''
|
266
|
+
end
|
267
|
+
elsif token =~ /@media/i
|
268
|
+
# found '@media', reset current media_types
|
269
|
+
in_at_media_rule = true
|
270
|
+
media_types = []
|
271
|
+
elsif in_at_media_rule
|
272
|
+
if token =~ /\{/
|
273
|
+
block_depth = block_depth + 1
|
274
|
+
in_at_media_rule = false
|
275
|
+
else
|
276
|
+
token.gsub!(/[,\s]*/, '')
|
277
|
+
media_types << token.strip.downcase.to_sym unless token.empty?
|
278
|
+
end
|
279
|
+
elsif in_charset or token =~ /@charset/i
|
280
|
+
# iterate until we are out of the charset declaration
|
281
|
+
in_charset = (token =~ /;/ ? false : true)
|
282
|
+
else
|
283
|
+
if token =~ /\}/ and not in_string
|
284
|
+
block_depth = block_depth - 1
|
285
|
+
else
|
286
|
+
if token =~ /\{/ and not in_string
|
287
|
+
current_selectors.gsub!(/^[\s]*/, '')
|
288
|
+
current_selectors.gsub!(/[\s]*$/, '')
|
289
|
+
in_declarations = true
|
290
|
+
else
|
291
|
+
current_selectors += token
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
# Load a remote CSS file.
|
299
|
+
def load_uri!(uri, base_uri = nil, media_types = :all)
|
300
|
+
base_uri = uri if base_uri.nil?
|
301
|
+
src, charset = read_remote_file(uri)
|
302
|
+
|
303
|
+
add_block!(src, {:media_types => media_types, :base_uri => base_uri})
|
304
|
+
end
|
305
|
+
|
306
|
+
# Load a local CSS file.
|
307
|
+
def load_file!(file_name, base_dir = nil, media_types = :all)
|
308
|
+
file_name = File.expand_path(file_name, base_dir)
|
309
|
+
return unless File.readable?(file_name)
|
310
|
+
|
311
|
+
src = IO.read(file_name)
|
312
|
+
base_dir = File.dirname(file_name)
|
313
|
+
|
314
|
+
add_block!(src, {:media_types => media_types, :base_dir => base_dir})
|
315
|
+
end
|
316
|
+
|
317
|
+
|
318
|
+
|
319
|
+
protected
|
320
|
+
# Strip comments and clean up blank lines from a block of CSS.
|
321
|
+
#
|
322
|
+
# Returns a string.
|
323
|
+
def cleanup_block(block) # :nodoc:
|
324
|
+
# Strip CSS comments
|
325
|
+
block.gsub!(STRIP_CSS_COMMENTS_RX, '')
|
326
|
+
|
327
|
+
# Strip HTML comments - they shouldn't really be in here but
|
328
|
+
# some people are just crazy...
|
329
|
+
block.gsub!(STRIP_HTML_COMMENTS_RX, '')
|
330
|
+
|
331
|
+
# Strip lines containing just whitespace
|
332
|
+
block.gsub!(/^\s+$/, "")
|
333
|
+
|
334
|
+
block
|
335
|
+
end
|
336
|
+
|
337
|
+
# Download a file into a string.
|
338
|
+
#
|
339
|
+
# Returns the file's data and character set in an array.
|
340
|
+
#--
|
341
|
+
# TODO: add option to fail silently or throw and exception on a 404
|
342
|
+
#++
|
343
|
+
def read_remote_file(uri) # :nodoc:
|
344
|
+
raise CircularReferenceError, "can't load #{uri.to_s} more than once" if @loaded_uris.include?(uri.to_s)
|
345
|
+
@loaded_uris << uri.to_s
|
346
|
+
|
347
|
+
begin
|
348
|
+
#fh = open(uri, 'rb')
|
349
|
+
fh = open(uri, 'rb', 'User-Agent' => USER_AGENT, 'Accept-Encoding' => 'gzip')
|
350
|
+
|
351
|
+
if fh.content_encoding.include?('gzip')
|
352
|
+
remote_src = Zlib::GzipReader.new(fh).read
|
353
|
+
else
|
354
|
+
remote_src = fh.read
|
355
|
+
end
|
356
|
+
|
357
|
+
#puts "reading #{uri} (#{fh.charset})"
|
358
|
+
|
359
|
+
ic = Iconv.new('UTF-8//IGNORE', fh.charset)
|
360
|
+
src = ic.iconv(remote_src)
|
361
|
+
|
362
|
+
fh.close
|
363
|
+
return src, fh.charset
|
364
|
+
rescue
|
365
|
+
raise RemoteFileError if @options[:io_exceptions]
|
366
|
+
return '', nil
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
private
|
371
|
+
# Save a folded declaration block to the internal cache.
|
372
|
+
def save_folded_declaration(block_hash, folded_declaration) # :nodoc:
|
373
|
+
@folded_declaration_cache[block_hash] = folded_declaration
|
374
|
+
end
|
375
|
+
|
376
|
+
# Retrieve a folded declaration block from the internal cache.
|
377
|
+
def get_folded_declaration(block_hash) # :nodoc:
|
378
|
+
return @folded_declaration_cache[block_hash] ||= nil
|
379
|
+
end
|
380
|
+
|
381
|
+
def reset! # :nodoc:
|
382
|
+
@folded_declaration_cache = {}
|
383
|
+
@css_source = ''
|
384
|
+
@css_rules = []
|
385
|
+
@css_warnings = []
|
386
|
+
end
|
387
|
+
end
|
388
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module CssParserMaster
|
2
|
+
# :stopdoc:
|
3
|
+
# Base types
|
4
|
+
RE_NL = Regexp.new('(\n|\r\n|\r|\f)')
|
5
|
+
RE_NON_ASCII = Regexp.new('([\x00-\xFF])', Regexp::IGNORECASE, 'n') #[^\0-\177]
|
6
|
+
RE_UNICODE = Regexp.new('(\\\\[0-9a-f]{1,6}(\r\n|[ \n\r\t\f])*)', Regexp::IGNORECASE | Regexp::EXTENDED | Regexp::MULTILINE, 'n')
|
7
|
+
RE_ESCAPE = Regexp.union(RE_UNICODE, '|(\\\\[^\n\r\f0-9a-f])')
|
8
|
+
RE_IDENT = Regexp.new("[\-]?([_a-z]|#{RE_NON_ASCII}|#{RE_ESCAPE})([_a-z0-9\-]|#{RE_NON_ASCII}|#{RE_ESCAPE})*", Regexp::IGNORECASE, 'n')
|
9
|
+
|
10
|
+
# General strings
|
11
|
+
RE_STRING1 = Regexp.new('(\"(.[^\n\r\f\\"]*|\\\\' + RE_NL.to_s + '|' + RE_ESCAPE.to_s + ')*\")')
|
12
|
+
RE_STRING2 = Regexp.new('(\'(.[^\n\r\f\\\']*|\\\\' + RE_NL.to_s + '|' + RE_ESCAPE.to_s + ')*\')')
|
13
|
+
RE_STRING = Regexp.union(RE_STRING1, RE_STRING2)
|
14
|
+
|
15
|
+
RE_URI = Regexp.new('(url\([\s]*([\s]*' + RE_STRING.to_s + '[\s]*)[\s]*\))|(url\([\s]*([!#$%&*\-~]|' + RE_NON_ASCII.to_s + '|' + RE_ESCAPE.to_s + ')*[\s]*)\)', Regexp::IGNORECASE | Regexp::EXTENDED | Regexp::MULTILINE, 'n')
|
16
|
+
URI_RX = /url\(("([^"]*)"|'([^']*)'|([^)]*))\)/im
|
17
|
+
|
18
|
+
# Initial parsing
|
19
|
+
RE_AT_IMPORT_RULE = /\@import[\s]+(url\()?["''"]?(.[^'"\s"']*)["''"]?\)?([\w\s\,^\])]*)\)?;?/
|
20
|
+
|
21
|
+
#--
|
22
|
+
#RE_AT_MEDIA_RULE = Regexp.new('(\"(.[^\n\r\f\\"]*|\\\\' + RE_NL.to_s + '|' + RE_ESCAPE.to_s + ')*\")')
|
23
|
+
|
24
|
+
#RE_AT_IMPORT_RULE = Regexp.new('@import[\s]*(' + RE_STRING.to_s + ')([\w\s\,]*)[;]?', Regexp::IGNORECASE) -- should handle url() even though it is not allowed
|
25
|
+
#++
|
26
|
+
IMPORTANT_IN_PROPERTY_RX = /[\s]*\!important[\s]*/i
|
27
|
+
STRIP_CSS_COMMENTS_RX = /\/\*.*?\*\//m
|
28
|
+
STRIP_HTML_COMMENTS_RX = /\<\!\-\-|\-\-\>/m
|
29
|
+
|
30
|
+
# Special units
|
31
|
+
BOX_MODEL_UNITS_RX = /(auto|inherit|0|([\-]*([0-9]+|[0-9]*\.[0-9]+)(e[mx]+|px|[cm]+m|p[tc+]|in|\%)))([\s;]|\Z)/imx
|
32
|
+
RE_LENGTH_OR_PERCENTAGE = Regexp.new('([\-]*(([0-9]*\.[0-9]+)|[0-9]+)(e[mx]+|px|[cm]+m|p[tc+]|in|\%))', Regexp::IGNORECASE)
|
33
|
+
RE_BACKGROUND_POSITION = Regexp.new("((#{RE_LENGTH_OR_PERCENTAGE})|left|center|right|top|bottom)", Regexp::IGNORECASE | Regexp::EXTENDED)
|
34
|
+
FONT_UNITS_RX = /(([x]+\-)*small|medium|large[r]*|auto|inherit|([0-9]+|[0-9]*\.[0-9]+)(e[mx]+|px|[cm]+m|p[tc+]|in|\%)*)/i
|
35
|
+
|
36
|
+
# Patterns for specificity calculations
|
37
|
+
ELEMENTS_AND_PSEUDO_ELEMENTS_RX = /((^|[\s\+\>]+)[\w]+|\:(first\-line|first\-letter|before|after))/i
|
38
|
+
NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX = /(\.[\w]+)|(\[[\w]+)|(\:(link|first\-child|lang))/i
|
39
|
+
|
40
|
+
# Colours
|
41
|
+
RE_COLOUR_RGB = Regexp.new('(rgb[\s]*\([\s-]*[\d]+(\.[\d]+)?[%\s]*,[\s-]*[\d]+(\.[\d]+)?[%\s]*,[\s-]*[\d]+(\.[\d]+)?[%\s]*\))', Regexp::IGNORECASE)
|
42
|
+
RE_COLOUR_HEX = /(#([0-9a-f]{6}|[0-9a-f]{3})([\s;]|$))/i
|
43
|
+
RE_COLOUR_NAMED = /([\s]*^)?(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow|transparent)([\s]*$)?/i
|
44
|
+
RE_COLOUR = Regexp.union(RE_COLOUR_RGB, RE_COLOUR_HEX, RE_COLOUR_NAMED)
|
45
|
+
# :startdoc:
|
46
|
+
end
|
@@ -0,0 +1,337 @@
|
|
1
|
+
require 'css_parser_master/selector'
|
2
|
+
require 'css_parser_master/selectors'
|
3
|
+
require 'css_parser_master/declaration'
|
4
|
+
require 'css_parser_master/declaration_api'
|
5
|
+
require 'css_parser_master/declarations'
|
6
|
+
|
7
|
+
module CssParserMaster
|
8
|
+
class RuleSet
|
9
|
+
# Patterns for specificity calculations
|
10
|
+
RE_ELEMENTS_AND_PSEUDO_ELEMENTS = /((^|[\s\+\>]+)[\w]+|\:(first\-line|first\-letter|before|after))/i
|
11
|
+
RE_NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES = /(\.[\w]+)|(\[[\w]+)|(\:(link|first\-child|lang))/i
|
12
|
+
|
13
|
+
include DeclarationAPI
|
14
|
+
|
15
|
+
# Array of selector strings.
|
16
|
+
attr_reader :selectors
|
17
|
+
|
18
|
+
# Integer with the specificity to use for this RuleSet.
|
19
|
+
attr_accessor :specificity
|
20
|
+
|
21
|
+
def initialize(selectors, block, specificity = nil)
|
22
|
+
@selectors = []
|
23
|
+
@specificity = specificity
|
24
|
+
@declarations = {}
|
25
|
+
@order = 0
|
26
|
+
parse_selectors!(selectors) if selectors
|
27
|
+
parse_declarations!(block)
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
# Get the value of a property
|
32
|
+
def get_value(property)
|
33
|
+
return '' unless property and not property.empty?
|
34
|
+
|
35
|
+
property = property.downcase.strip
|
36
|
+
properties = @declarations.inject('') do |val, (key, data)|
|
37
|
+
#puts "COMPARING #{key} #{key.inspect} against #{property} #{property.inspect}"
|
38
|
+
importance = data[:is_important] ? ' !important' : ''
|
39
|
+
val << "#{data[:value]}#{importance}; " if key.downcase.strip == property
|
40
|
+
val
|
41
|
+
end
|
42
|
+
return properties ? properties.strip : ''
|
43
|
+
end
|
44
|
+
alias_method :[], :get_value
|
45
|
+
|
46
|
+
# Iterate through selectors.
|
47
|
+
#
|
48
|
+
# Options
|
49
|
+
# - +force_important+ -- boolean
|
50
|
+
#
|
51
|
+
# ==== Example
|
52
|
+
# ruleset.each_selector do |sel, dec, spec|
|
53
|
+
# ...
|
54
|
+
# end
|
55
|
+
def each_selector(options = {}) # :yields: selector, declarations, specificity
|
56
|
+
declarations = declarations_to_s(options)
|
57
|
+
# puts "declarations: #{declarations.inspect}"
|
58
|
+
if @specificity
|
59
|
+
@selectors.each { |sel| yield Selector.new sel.strip, declarations, @specificity }
|
60
|
+
else
|
61
|
+
@selectors.each { |sel| yield Selector.new sel.strip, declarations, CssParserMaster.calculate_specificity(sel) }
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Return the CSS rule set as a string.
|
66
|
+
def to_s
|
67
|
+
decs = declarations_to_s
|
68
|
+
"#{@selectors} { #{decs} }"
|
69
|
+
end
|
70
|
+
|
71
|
+
# Split shorthand declarations (e.g. +margin+ or +font+) into their constituent parts.
|
72
|
+
def expand_shorthand!
|
73
|
+
expand_dimensions_shorthand!
|
74
|
+
expand_font_shorthand!
|
75
|
+
expand_background_shorthand!
|
76
|
+
end
|
77
|
+
|
78
|
+
# Create shorthand declarations (e.g. +margin+ or +font+) whenever possible.
|
79
|
+
def create_shorthand!
|
80
|
+
create_background_shorthand!
|
81
|
+
create_dimensions_shorthand!
|
82
|
+
create_font_shorthand!
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
|
88
|
+
#--
|
89
|
+
# TODO: way too simplistic
|
90
|
+
#++
|
91
|
+
def parse_selectors!(selectors) # :nodoc:
|
92
|
+
@selectors = selectors.split(',')
|
93
|
+
end
|
94
|
+
|
95
|
+
public
|
96
|
+
# Split shorthand dimensional declarations (e.g. <tt>margin: 0px auto;</tt>)
|
97
|
+
# into their constituent parts.
|
98
|
+
def expand_dimensions_shorthand! # :nodoc:
|
99
|
+
['margin', 'padding'].each do |property|
|
100
|
+
|
101
|
+
next unless @declarations.has_key?(property)
|
102
|
+
|
103
|
+
value = @declarations[property][:value]
|
104
|
+
is_important = @declarations[property][:is_important]
|
105
|
+
order = @declarations[property][:order]
|
106
|
+
t, r, b, l = nil
|
107
|
+
|
108
|
+
matches = value.scan(CssParserMaster::BOX_MODEL_UNITS_RX)
|
109
|
+
|
110
|
+
case matches.length
|
111
|
+
when 1
|
112
|
+
t, r, b, l = matches[0][0], matches[0][0], matches[0][0], matches[0][0]
|
113
|
+
when 2
|
114
|
+
t, b = matches[0][0], matches[0][0]
|
115
|
+
r, l = matches[1][0], matches[1][0]
|
116
|
+
when 3
|
117
|
+
t = matches[0][0]
|
118
|
+
r, l = matches[1][0], matches[1][0]
|
119
|
+
b = matches[2][0]
|
120
|
+
when 4
|
121
|
+
t = matches[0][0]
|
122
|
+
r = matches[1][0]
|
123
|
+
b = matches[2][0]
|
124
|
+
l = matches[3][0]
|
125
|
+
end
|
126
|
+
|
127
|
+
values = { :is_important => is_important, :order => order }
|
128
|
+
@declarations["#{property}-top"] = values.merge(:value => t.to_s)
|
129
|
+
@declarations["#{property}-right"] = values.merge(:value => r.to_s)
|
130
|
+
@declarations["#{property}-bottom"] = values.merge(:value => b.to_s)
|
131
|
+
@declarations["#{property}-left"] = values.merge(:value => l.to_s)
|
132
|
+
@declarations.delete(property)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Convert shorthand font declarations (e.g. <tt>font: 300 italic 11px/14px verdana, helvetica, sans-serif;</tt>)
|
137
|
+
# into their constituent parts.
|
138
|
+
def expand_font_shorthand! # :nodoc:
|
139
|
+
return unless @declarations.has_key?('font')
|
140
|
+
|
141
|
+
font_props = {}
|
142
|
+
|
143
|
+
# reset properties to 'normal' per http://www.w3.org/TR/CSS21/fonts.html#font-shorthand
|
144
|
+
['font-style', 'font-variant', 'font-weight', 'font-size',
|
145
|
+
'line-height'].each do |prop|
|
146
|
+
font_props[prop] = 'normal'
|
147
|
+
end
|
148
|
+
|
149
|
+
value = @declarations['font'][:value]
|
150
|
+
is_important = @declarations['font'][:is_important]
|
151
|
+
order = @declarations['font'][:order]
|
152
|
+
|
153
|
+
in_fonts = false
|
154
|
+
|
155
|
+
matches = value.scan(/("(.*[^"])"|'(.*[^'])'|(\w[^ ,]+))/)
|
156
|
+
matches.each do |match|
|
157
|
+
m = match[0].to_s.strip
|
158
|
+
m.gsub!(/[;]$/, '')
|
159
|
+
|
160
|
+
if in_fonts
|
161
|
+
if font_props.has_key?('font-family')
|
162
|
+
font_props['font-family'] += ', ' + m
|
163
|
+
else
|
164
|
+
font_props['font-family'] = m
|
165
|
+
end
|
166
|
+
elsif m =~ /normal|inherit/i
|
167
|
+
['font-style', 'font-weight', 'font-variant'].each do |font_prop|
|
168
|
+
font_props[font_prop] = m unless font_props.has_key?(font_prop)
|
169
|
+
end
|
170
|
+
elsif m =~ /italic|oblique/i
|
171
|
+
font_props['font-style'] = m
|
172
|
+
elsif m =~ /small\-caps/i
|
173
|
+
font_props['font-variant'] = m
|
174
|
+
elsif m =~ /[1-9]00$|bold|bolder|lighter/i
|
175
|
+
font_props['font-weight'] = m
|
176
|
+
elsif m =~ CssParserMaster::FONT_UNITS_RX
|
177
|
+
if m =~ /\//
|
178
|
+
font_props['font-size'], font_props['line-height'] = m.split('/')
|
179
|
+
else
|
180
|
+
font_props['font-size'] = m
|
181
|
+
end
|
182
|
+
in_fonts = true
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
font_props.each { |font_prop, font_val| @declarations[font_prop] = {:value => font_val, :is_important => is_important, :order => order} }
|
187
|
+
|
188
|
+
@declarations.delete('font')
|
189
|
+
end
|
190
|
+
|
191
|
+
|
192
|
+
# Convert shorthand background declarations (e.g. <tt>background: url("chess.png") gray 50% repeat fixed;</tt>)
|
193
|
+
# into their constituent parts.
|
194
|
+
#
|
195
|
+
# See http://www.w3.org/TR/CSS21/colors.html#propdef-background
|
196
|
+
def expand_background_shorthand! # :nodoc:
|
197
|
+
return unless @declarations.has_key?('background')
|
198
|
+
|
199
|
+
value = @declarations['background'][:value]
|
200
|
+
is_important = @declarations['background'][:is_important]
|
201
|
+
order = @declarations['background'][:order]
|
202
|
+
|
203
|
+
bg_props = {}
|
204
|
+
|
205
|
+
|
206
|
+
if m = value.match(Regexp.union(CssParserMaster::URI_RX, /none/i)).to_s
|
207
|
+
bg_props['background-image'] = m.strip unless m.empty?
|
208
|
+
value.gsub!(Regexp.union(CssParserMaster::URI_RX, /none/i), '')
|
209
|
+
end
|
210
|
+
|
211
|
+
if m = value.match(/([\s]*^)?(scroll|fixed)([\s]*$)?/i).to_s
|
212
|
+
bg_props['background-attachment'] = m.strip unless m.empty?
|
213
|
+
end
|
214
|
+
|
215
|
+
if m = value.match(/([\s]*^)?(repeat(\-x|\-y)*|no\-repeat)([\s]*$)?/i).to_s
|
216
|
+
bg_props['background-repeat'] = m.strip unless m.empty?
|
217
|
+
end
|
218
|
+
|
219
|
+
if m = value.match(CssParserMaster::RE_COLOUR).to_s
|
220
|
+
bg_props['background-color'] = m.strip unless m.empty?
|
221
|
+
end
|
222
|
+
|
223
|
+
value.scan(CssParserMaster::RE_BACKGROUND_POSITION).each do |m|
|
224
|
+
if bg_props.has_key?('background-position')
|
225
|
+
bg_props['background-position'] += ' ' + m[0].to_s.strip unless m.empty?
|
226
|
+
else
|
227
|
+
bg_props['background-position'] = m[0].to_s.strip unless m.empty?
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
|
232
|
+
if value =~ /([\s]*^)?inherit([\s]*$)?/i
|
233
|
+
['background-color', 'background-image', 'background-attachment', 'background-repeat', 'background-position'].each do |prop|
|
234
|
+
bg_props["#{prop}"] = 'inherit' unless bg_props.has_key?(prop) and not bg_props[prop].empty?
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
bg_props.each { |bg_prop, bg_val| @declarations[bg_prop] = {:value => bg_val, :is_important => is_important, :order => order} }
|
239
|
+
|
240
|
+
@declarations.delete('background')
|
241
|
+
end
|
242
|
+
|
243
|
+
|
244
|
+
# Looks for long format CSS background properties (e.g. <tt>background-color</tt>) and
|
245
|
+
# converts them into a shorthand CSS <tt>background</tt> property.
|
246
|
+
def create_background_shorthand! # :nodoc:
|
247
|
+
new_value = ''
|
248
|
+
['background-color', 'background-image', 'background-repeat',
|
249
|
+
'background-position', 'background-attachment'].each do |property|
|
250
|
+
if @declarations.has_key?(property)
|
251
|
+
new_value += @declarations[property][:value] + ' '
|
252
|
+
@declarations.delete(property)
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
unless new_value.strip.empty?
|
257
|
+
@declarations['background'] = {:value => new_value.gsub(/[\s]+/, ' ').strip}
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
# Looks for long format CSS dimensional properties (i.e. <tt>margin</tt> and <tt>padding</tt>) and
|
262
|
+
# converts them into shorthand CSS properties.
|
263
|
+
def create_dimensions_shorthand! # :nodoc:
|
264
|
+
# geometric
|
265
|
+
directions = ['top', 'right', 'bottom', 'left']
|
266
|
+
['margin', 'padding'].each do |property|
|
267
|
+
values = {}
|
268
|
+
|
269
|
+
foldable = @declarations.select { |dim, val| dim == "#{property}-top" or dim == "#{property}-right" or dim == "#{property}-bottom" or dim == "#{property}-left" }
|
270
|
+
# All four dimensions must be present
|
271
|
+
if foldable.length == 4
|
272
|
+
values = {}
|
273
|
+
|
274
|
+
directions.each { |d| values[d.to_sym] = @declarations["#{property}-#{d}"][:value].downcase.strip }
|
275
|
+
|
276
|
+
if values[:left] == values[:right]
|
277
|
+
if values[:top] == values[:bottom]
|
278
|
+
if values[:top] == values[:left] # All four sides are equal
|
279
|
+
new_value = values[:top]
|
280
|
+
else # Top and bottom are equal, left and right are equal
|
281
|
+
new_value = values[:top] + ' ' + values[:left]
|
282
|
+
end
|
283
|
+
else # Only left and right are equal
|
284
|
+
new_value = values[:top] + ' ' + values[:left] + ' ' + values[:bottom]
|
285
|
+
end
|
286
|
+
else # No sides are equal
|
287
|
+
new_value = values[:top] + ' ' + values[:right] + ' ' + values[:bottom] + ' ' + values[:left]
|
288
|
+
end # done creating 'new_value'
|
289
|
+
|
290
|
+
# Save the new value
|
291
|
+
unless new_value.strip.empty?
|
292
|
+
@declarations[property] = {:value => new_value.gsub(/[\s]+/, ' ').strip}
|
293
|
+
end
|
294
|
+
|
295
|
+
# Delete the shorthand values
|
296
|
+
directions.each { |d| @declarations.delete("#{property}-#{d}") }
|
297
|
+
end
|
298
|
+
end # done iterating through margin and padding
|
299
|
+
end
|
300
|
+
|
301
|
+
|
302
|
+
# Looks for long format CSS font properties (e.g. <tt>font-weight</tt>) and
|
303
|
+
# tries to convert them into a shorthand CSS <tt>font</tt> property. All
|
304
|
+
# font properties must be present in order to create a shorthand declaration.
|
305
|
+
def create_font_shorthand! # :nodoc:
|
306
|
+
['font-style', 'font-variant', 'font-weight', 'font-size',
|
307
|
+
'line-height', 'font-family'].each do |prop|
|
308
|
+
return unless @declarations.has_key?(prop)
|
309
|
+
end
|
310
|
+
|
311
|
+
new_value = ''
|
312
|
+
['font-style', 'font-variant', 'font-weight'].each do |property|
|
313
|
+
unless @declarations[property][:value] == 'normal'
|
314
|
+
new_value += @declarations[property][:value] + ' '
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
new_value += @declarations['font-size'][:value]
|
319
|
+
|
320
|
+
unless @declarations['line-height'][:value] == 'normal'
|
321
|
+
new_value += '/' + @declarations['line-height'][:value]
|
322
|
+
end
|
323
|
+
|
324
|
+
new_value += ' ' + @declarations['font-family'][:value]
|
325
|
+
|
326
|
+
@declarations['font'] = {:value => new_value.gsub(/[\s]+/, ' ').strip}
|
327
|
+
|
328
|
+
['font-style', 'font-variant', 'font-weight', 'font-size',
|
329
|
+
'line-height', 'font-family'].each do |prop|
|
330
|
+
@declarations.delete(prop)
|
331
|
+
end
|
332
|
+
|
333
|
+
end
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
|