css_parser_master 1.2.4
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.
- 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
|
+
|