JezK
Edit File: rule_set.rb
# frozen_string_literal: true module CssParser class RuleSet # Patterns for specificity calculations RE_ELEMENTS_AND_PSEUDO_ELEMENTS = /((^|[\s\+\>]+)[\w]+|\:(first\-line|first\-letter|before|after))/i RE_NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES = /(\.[\w]+)|(\[[\w]+)|(\:(link|first\-child|lang))/i BACKGROUND_PROPERTIES = ['background-color', 'background-image', 'background-repeat', 'background-position', 'background-size', 'background-attachment'] LIST_STYLE_PROPERTIES = ['list-style-type', 'list-style-position', 'list-style-image'] FONT_STYLE_PROPERTIES = ['font-style', 'font-variant', 'font-weight', 'font-size', 'line-height', 'font-family'] BORDER_STYLE_PROPERTIES = ['border-width', 'border-style', 'border-color'] BORDER_PROPERTIES = ['border', 'border-left', 'border-right', 'border-top', 'border-bottom'] NUMBER_OF_DIMENSIONS = 4 DIMENSIONS = [ ['margin', %w(margin-top margin-right margin-bottom margin-left)], ['padding', %w(padding-top padding-right padding-bottom padding-left)], ['border-color', %w(border-top-color border-right-color border-bottom-color border-left-color)], ['border-style', %w(border-top-style border-right-style border-bottom-style border-left-style)], ['border-width', %w(border-top-width border-right-width border-bottom-width border-left-width)], ] # Array of selector strings. attr_reader :selectors # Integer with the specificity to use for this RuleSet. attr_accessor :specificity def initialize(selectors, block, specificity = nil) @selectors = [] @specificity = specificity @declarations = {} @order = 0 parse_selectors!(selectors) if selectors parse_declarations!(block) end # Get the value of a property def get_value(property) return '' unless property and not property.empty? property = property.downcase.strip properties = @declarations.inject(String.new) do |val, (key, data)| #puts "COMPARING #{key} #{key.inspect} against #{property} #{property.inspect}" importance = data[:is_important] ? ' !important' : '' val << "#{data[:value]}#{importance}; " if key.downcase.strip == property val end return properties ? properties.strip : '' end alias_method :[], :get_value # Add a CSS declaration to the current RuleSet. # # rule_set.add_declaration!('color', 'blue') # # puts rule_set['color'] # => 'blue;' # # rule_set.add_declaration!('margin', '0px auto !important') # # puts rule_set['margin'] # => '0px auto !important;' # # If the property already exists its value will be over-written. def add_declaration!(property, value) if value.nil? or value.empty? @declarations.delete(property) return end value.gsub!(/;\Z/, '') is_important = !value.gsub!(CssParser::IMPORTANT_IN_PROPERTY_RX, '').nil? property = property.downcase property.strip! #puts "SAVING #{property} #{value} #{is_important.inspect}" @declarations[property] = { :value => value, :is_important => is_important, :order => @order += 1 } end alias_method :[]=, :add_declaration! # Remove CSS declaration from the current RuleSet. # # rule_set.remove_declaration!('color') def remove_declaration!(property) @declarations.delete(property) end # Iterate through selectors. # # Options # - +force_important+ -- boolean # # ==== Example # ruleset.each_selector do |sel, dec, spec| # ... # end def each_selector(options = {}) # :yields: selector, declarations, specificity declarations = declarations_to_s(options) if @specificity @selectors.each { |sel| yield sel.strip, declarations, @specificity } else @selectors.each { |sel| yield sel.strip, declarations, CssParser.calculate_specificity(sel) } end end # Iterate through declarations. def each_declaration # :yields: property, value, is_important decs = @declarations.sort { |a,b| a[1][:order].nil? || b[1][:order].nil? ? 0 : a[1][:order] <=> b[1][:order] } decs.each do |property, data| value = data[:value] yield property.downcase.strip, value.strip, data[:is_important] end end # Return all declarations as a string. #-- # TODO: Clean-up regexp doesn't seem to work #++ def declarations_to_s(options = {}) str = String.new each_declaration do |prop, val, is_important| importance = (options[:force_important] || is_important) ? ' !important' : '' str << "#{prop}: #{val}#{importance}; " end str.gsub!(/^[\s^(\{)]+|[\n\r\f\t]*|[\s]+$/mx, '') str.strip! str end # Return the CSS rule set as a string. def to_s decs = declarations_to_s "#{@selectors.join(',')} { #{decs} }" end # Split shorthand declarations (e.g. +margin+ or +font+) into their constituent parts. def expand_shorthand! # border must be expanded before dimensions expand_border_shorthand! expand_dimensions_shorthand! expand_font_shorthand! expand_background_shorthand! expand_list_style_shorthand! end # Convert shorthand background declarations (e.g. <tt>background: url("chess.png") gray 50% repeat fixed;</tt>) # into their constituent parts. # # See http://www.w3.org/TR/CSS21/colors.html#propdef-background def expand_background_shorthand! # :nodoc: return unless @declarations.has_key?('background') value = @declarations['background'][:value] if value =~ CssParser::RE_INHERIT BACKGROUND_PROPERTIES.each do |prop| split_declaration('background', prop, 'inherit') end end split_declaration('background', 'background-image', value.slice!(Regexp.union(CssParser::URI_RX, CssParser::RE_GRADIENT, /none/i))) split_declaration('background', 'background-attachment', value.slice!(CssParser::RE_SCROLL_FIXED)) split_declaration('background', 'background-repeat', value.slice!(CssParser::RE_REPEAT)) split_declaration('background', 'background-color', value.slice!(CssParser::RE_COLOUR)) split_declaration('background', 'background-size', extract_background_size_from(value)) split_declaration('background', 'background-position', value.slice(CssParser::RE_BACKGROUND_POSITION)) @declarations.delete('background') end def extract_background_size_from(value) size = value.slice!(CssParser::RE_BACKGROUND_SIZE) size.sub(/^\s*\/\s*/, '') if size end # Split shorthand border declarations (e.g. <tt>border: 1px red;</tt>) # Additional splitting happens in expand_dimensions_shorthand! def expand_border_shorthand! # :nodoc: BORDER_PROPERTIES.each do |k| next unless @declarations.has_key?(k) value = @declarations[k][:value] split_declaration(k, "#{k}-width", value.slice!(CssParser::RE_BORDER_UNITS)) split_declaration(k, "#{k}-color", value.slice!(CssParser::RE_COLOUR)) split_declaration(k, "#{k}-style", value.slice!(CssParser::RE_BORDER_STYLE)) @declarations.delete(k) end end # Split shorthand dimensional declarations (e.g. <tt>margin: 0px auto;</tt>) # into their constituent parts. Handles margin, padding, border-color, border-style and border-width. def expand_dimensions_shorthand! # :nodoc: DIMENSIONS.each do |property, (top, right, bottom, left)| next unless @declarations.has_key?(property) value = @declarations[property][:value] # RGB and HSL values in borders are the only units that can have spaces (within params). # We cheat a bit here by stripping spaces after commas in RGB and HSL values so that we # can split easily on spaces. # # TODO: rgba, hsl, hsla value.gsub!(RE_COLOUR) { |c| c.gsub(/(\s*\,\s*)/, ',') } matches = value.strip.split(/\s+/) case matches.length when 1 values = matches.to_a * 4 when 2 values = matches.to_a * 2 when 3 values = matches.to_a values << matches[1] # left = right when 4 values = matches.to_a end t, r, b, l = values split_declaration(property, top, t) split_declaration(property, right, r) split_declaration(property, bottom, b) split_declaration(property, left, l) @declarations.delete(property) end end # Convert shorthand font declarations (e.g. <tt>font: 300 italic 11px/14px verdana, helvetica, sans-serif;</tt>) # into their constituent parts. def expand_font_shorthand! # :nodoc: return unless @declarations.has_key?('font') font_props = {} # reset properties to 'normal' per http://www.w3.org/TR/CSS21/fonts.html#font-shorthand ['font-style', 'font-variant', 'font-weight', 'font-size', 'line-height'].each do |prop| font_props[prop] = 'normal' end value = @declarations['font'][:value] value.gsub!(/\/\s+/, '/') # handle spaces between font size and height shorthand (e.g. 14px/ 16px) is_important = @declarations['font'][:is_important] order = @declarations['font'][:order] in_fonts = false matches = value.scan(/("(.*[^"])"|'(.*[^'])'|(\w[^ ,]+))/) matches.each do |match| m = match[0].to_s.strip m.gsub!(/[;]$/, '') if in_fonts if font_props.has_key?('font-family') font_props['font-family'] += ', ' + m else font_props['font-family'] = m end elsif m =~ /normal|inherit/i ['font-style', 'font-weight', 'font-variant'].each do |font_prop| font_props[font_prop] = m unless font_props.has_key?(font_prop) end elsif m =~ /italic|oblique/i font_props['font-style'] = m elsif m =~ /small\-caps/i font_props['font-variant'] = m elsif m =~ /[1-9]00$|bold|bolder|lighter/i font_props['font-weight'] = m elsif m =~ CssParser::FONT_UNITS_RX if m =~ /\// font_props['font-size'], font_props['line-height'] = m.split('/') else font_props['font-size'] = m end in_fonts = true end end font_props.each { |font_prop, font_val| @declarations[font_prop] = {:value => font_val, :is_important => is_important, :order => order} } @declarations.delete('font') end # Convert shorthand list-style declarations (e.g. <tt>list-style: lower-alpha outside;</tt>) # into their constituent parts. # # See http://www.w3.org/TR/CSS21/generate.html#lists def expand_list_style_shorthand! # :nodoc: return unless @declarations.has_key?('list-style') value = @declarations['list-style'][:value] if value =~ CssParser::RE_INHERIT LIST_STYLE_PROPERTIES.each do |prop| split_declaration('list-style', prop, 'inherit') end end split_declaration('list-style', 'list-style-type', value.slice!(CssParser::RE_LIST_STYLE_TYPE)) split_declaration('list-style', 'list-style-position', value.slice!(CssParser::RE_INSIDE_OUTSIDE)) split_declaration('list-style', 'list-style-image', value.slice!(Regexp.union(CssParser::URI_RX, /none/i))) @declarations.delete('list-style') end # Create shorthand declarations (e.g. +margin+ or +font+) whenever possible. def create_shorthand! create_background_shorthand! create_dimensions_shorthand! # border must be shortened after dimensions create_border_shorthand! create_font_shorthand! create_list_style_shorthand! end # Combine several properties into a shorthand one def create_shorthand_properties! properties, shorthand_property # :nodoc: values = [] properties_to_delete = [] properties.each do |property| if @declarations.has_key?(property) and not @declarations[property][:is_important] values << @declarations[property][:value] properties_to_delete << property end end if values.length > 1 properties_to_delete.each do |property| @declarations.delete(property) end @declarations[shorthand_property] = {:value => values.join(' ')} end end # Looks for long format CSS background properties (e.g. <tt>background-color</tt>) and # converts them into a shorthand CSS <tt>background</tt> property. # # Leaves properties declared !important alone. def create_background_shorthand! # :nodoc: # When we have a background-size property we must separate it and distinguish it from # background-position by preceding it with a backslash. In this case we also need to # have a background-position property, so we set it if it's missing. # http://www.w3schools.com/cssref/css3_pr_background.asp if @declarations.has_key?('background-size') and not @declarations['background-size'][:is_important] unless @declarations.has_key?('background-position') @declarations['background-position'] = {:value => '0% 0%'} end @declarations['background-size'][:value] = "/ #{@declarations['background-size'][:value]}" end create_shorthand_properties! BACKGROUND_PROPERTIES, 'background' end # Combine border-color, border-style and border-width into border # Should be run after create_dimensions_shorthand! # # TODO: this is extremely similar to create_background_shorthand! and should be combined def create_border_shorthand! # :nodoc: values = [] BORDER_STYLE_PROPERTIES.each do |property| if @declarations.has_key?(property) and not @declarations[property][:is_important] # can't merge if any value contains a space (i.e. has multiple values) # we temporarily remove any spaces after commas for the check (inside rgba, etc...) return if @declarations[property][:value].gsub(/\,[\s]/, ',').strip =~ /[\s]/ values << @declarations[property][:value] end end BORDER_STYLE_PROPERTIES.each { |prop| @declarations.delete(prop)} unless values.empty? @declarations['border'] = {:value => values.join(' ')} end end # Looks for long format CSS dimensional properties (margin, padding, border-color, border-style and border-width) # and converts them into shorthand CSS properties. def create_dimensions_shorthand! # :nodoc: return if @declarations.size < NUMBER_OF_DIMENSIONS DIMENSIONS.each do |property, dimensions| (top, right, bottom, left) = dimensions # All four dimensions must be present if dimensions.count { |d| @declarations[d] } == NUMBER_OF_DIMENSIONS values = {} [ [:top, top], [:right, right], [:bottom, bottom], [:left, left], ].each { |d, key| values[d] = @declarations[key][:value].downcase.strip } if values[:left] == values[:right] if values[:top] == values[:bottom] if values[:top] == values[:left] # All four sides are equal new_value = values[:top] else # Top and bottom are equal, left and right are equal new_value = values[:top] + ' ' + values[:left] end else # Only left and right are equal new_value = values[:top] + ' ' + values[:left] + ' ' + values[:bottom] end else # No sides are equal new_value = values[:top] + ' ' + values[:right] + ' ' + values[:bottom] + ' ' + values[:left] end new_value.strip! @declarations[property] = {:value => new_value.strip} unless new_value.empty? # Delete the longhand values [top, right, bottom, left].each { |d| @declarations.delete(d) } end end end # Looks for long format CSS font properties (e.g. <tt>font-weight</tt>) and # tries to convert them into a shorthand CSS <tt>font</tt> property. All # font properties must be present in order to create a shorthand declaration. def create_font_shorthand! # :nodoc: FONT_STYLE_PROPERTIES.each do |prop| return unless @declarations.has_key?(prop) end new_value = String.new ['font-style', 'font-variant', 'font-weight'].each do |property| unless @declarations[property][:value] == 'normal' new_value << @declarations[property][:value] << ' ' end end new_value << @declarations['font-size'][:value] unless @declarations['line-height'][:value] == 'normal' new_value << '/' << @declarations['line-height'][:value] end new_value << ' ' << @declarations['font-family'][:value] @declarations['font'] = {:value => new_value.gsub(/[\s]+/, ' ').strip} FONT_STYLE_PROPERTIES.each { |prop| @declarations.delete(prop) } end # Looks for long format CSS list-style properties (e.g. <tt>list-style-type</tt>) and # converts them into a shorthand CSS <tt>list-style</tt> property. # # Leaves properties declared !important alone. def create_list_style_shorthand! # :nodoc: create_shorthand_properties! LIST_STYLE_PROPERTIES, 'list-style' end private # utility method for re-assign shorthand elements to longhand versions def split_declaration(src, dest, v) # :nodoc: return unless v and not v.empty? if @declarations.has_key?(dest) #puts "dest #{dest} already exists" if @declarations[src][:order].nil? || (!@declarations[dest][:order].nil? && @declarations[dest][:order] > @declarations[src][:order]) #puts "skipping #{dest}:#{v} due to order " return else @declarations[dest] = {} end end @declarations[dest] = @declarations[src].dup @declarations[dest][:value] = v.to_s.strip end def parse_declarations!(block) # :nodoc: @declarations = {} return unless block continuation = nil block.split(/[\;$]+/m).each do |decs| decs = continuation ? continuation + decs : decs if decs =~ /\([^)]*\Z/ # if it has an unmatched parenthesis continuation = decs + ';' elsif matches = decs.match(/\s*(.[^:]*)\s*:\s*(.+?)(;?\s*\Z)/i) # skip end_of_declaration property = matches[1] value = matches[2] add_declaration!(property, value) continuation = nil end end end #-- # TODO: way too simplistic #++ def parse_selectors!(selectors) # :nodoc: @selectors = selectors.split(',').map do |s| s.gsub!(/\s+/, ' ') s.strip! s end end end class OffsetAwareRuleSet < RuleSet # File offset range attr_reader :offset # the local or remote location attr_accessor :filename def initialize(filename, offset, selectors, block, specificity = nil) super(selectors, block, specificity) @offset = offset @filename = filename end end end