Skip to content

Commit

Permalink
Rebuild CSS values parser to be more standards compliant
Browse files Browse the repository at this point in the history
CSS values parsing was pretty basic before.  The CSS 2 graamar spec
doesn't actually spell out exactly how quoting should work, but it's
referenced elsewhere in the document, so this commit does its best
to implement quote parsing.  It also implements escaping as per the
spec.

Fixes #165
  • Loading branch information
mogest committed Jun 16, 2024
1 parent f98879d commit 6bd9870
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 68 deletions.
1 change: 1 addition & 0 deletions lib/prawn-svg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
require 'prawn/svg/css/font_family_parser'
require 'prawn/svg/css/selector_parser'
require 'prawn/svg/css/stylesheets'
require 'prawn/svg/css/values_parser'
require 'prawn/svg/ttf'
require 'prawn/svg/font'
require 'prawn/svg/gradients'
Expand Down
129 changes: 65 additions & 64 deletions lib/prawn/svg/color.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class Prawn::SVG::Color
RGB = Struct.new(:value)
CMYK = Struct.new(:value)

RGB_DEFAULT_COLOR = RGB.new("000000")
RGB_DEFAULT_COLOR = RGB.new('000000')
CMYK_DEFAULT_COLOR = CMYK.new([0, 0, 0, 100])

HTML_COLORS = {
Expand Down Expand Up @@ -155,90 +155,91 @@ class Prawn::SVG::Color
'yellowgreen' => '9acd32'
}.freeze

VALUE_REGEXP = "\s*(-?[0-9.]+%?)\s*"
RGB_REGEXP = /\Argb\(#{VALUE_REGEXP},#{VALUE_REGEXP},#{VALUE_REGEXP}\)\z/i
CMYK_REGEXP = /\Adevice-cmyk\(#{VALUE_REGEXP},#{VALUE_REGEXP},#{VALUE_REGEXP},#{VALUE_REGEXP}\)\z/i
URL_REGEXP = /\Aurl\(([^)]*)\)\z/i
class << self
def parse(color_string, gradients = nil, color_mode = :rgb)
url_specified = false

def self.parse(color_string, gradients = nil, color_mode = :rgb)
url_specified = false
values = ::Prawn::SVG::CSS::ValuesParser.parse(color_string)

components = color_string.to_s.strip.scan(/([^(\s]+(\([^)]*\))?)/)
result = values.map do |value|
case value
in ['rgb', args]
hex = (0..2).collect do |n|
number = args[n].to_f
number *= 2.55 if args[n][-1..] == '%'
format('%02x', clamp(number.round, 0, 255))
end.join

result = components.map do |color, *_|
if m = color.match(/\A#([0-9a-f])([0-9a-f])([0-9a-f])\z/i)
RGB.new("#{m[1] * 2}#{m[2] * 2}#{m[3] * 2}")
RGB.new(hex)

elsif color.match(/\A#[0-9a-f]{6}\z/i)
RGB.new(color[1..6])
in ['device-cmyk', args]
cmyk = (0..3).collect do |n|
number = args[n].to_f
number *= 100 unless args[n][-1..] == '%'
clamp(number, 0, 100)
end

elsif hex = HTML_COLORS[color.downcase]
hex_color(hex, color_mode)
CMYK.new(cmyk)

elsif m = color.match(RGB_REGEXP)
hex = (1..3).collect do |n|
value = m[n].to_f
value *= 2.55 if m[n][-1..-1] == '%'
"%02x" % clamp(value.round, 0, 255)
end.join
in ['url', [url]]
url_specified = true
if url[0] == '#' && gradients && (gradient = gradients[url[1..]])
gradient
end

RGB.new(hex)
in /\A#([0-9a-f])([0-9a-f])([0-9a-f])\z/i
RGB.new("#{$1 * 2}#{$2 * 2}#{$3 * 2}")

elsif m = color.match(CMYK_REGEXP)
cmyk = (1..4).collect do |n|
value = m[n].to_f
value *= 100 unless m[n][-1..-1] == '%'
clamp(value, 0, 100)
end
in /\A#[0-9a-f]{6}\z/i
RGB.new(value[1..])

CMYK.new(cmyk)
in String => color
if (hex = HTML_COLORS[color.downcase])
hex_color(hex, color_mode)
end

elsif matches = color.match(URL_REGEXP)
url_specified = true
url = matches[1]
if url[0] == "#" && gradients && gradient = gradients[url[1..-1]]
gradient
else
nil
end
end
end

# Generally, we default to black if the colour was unparseable.
# http://www.w3.org/TR/SVG/painting.html section 11.2 says if a URL was
# supplied without a fallback, that's an error.
result << default_color(color_mode) unless url_specified
# Generally, we default to black if the colour was unparseable.
# http://www.w3.org/TR/SVG/painting.html section 11.2 says if a URL was
# supplied without a fallback, that's an error.
result << default_color(color_mode) unless url_specified

result.compact
end
result.compact
end

def self.css_color_to_prawn_color(color)
result = parse(color).detect {|result| result.is_a?(RGB) || result.is_a?(CMYK)}
result.value if result
end
def css_color_to_prawn_color(color)
parse(color).detect { |value| value.is_a?(RGB) || value.is_a?(CMYK) }&.value
end

protected
def default_color(color_mode)
color_mode == :cmyk ? CMYK_DEFAULT_COLOR : RGB_DEFAULT_COLOR
end

def self.clamp(value, min_value, max_value)
[[value, min_value].max, max_value].min
end
private

def self.default_color(color_mode)
color_mode == :cmyk ? CMYK_DEFAULT_COLOR : RGB_DEFAULT_COLOR
end
def clamp(value, min_value, max_value)
[[value, min_value].max, max_value].min
end

def self.hex_color(hex, color_mode)
if color_mode == :cmyk
r, g, b = [hex[0..1], hex[2..3], hex[4..5]].map { |h| h.to_i(16) / 255.0 }
k = 1 - [r, g, b].max
if k == 1
CMYK.new([0, 0, 0, 100])
def hex_color(hex, color_mode)
if color_mode == :cmyk
r, g, b = [hex[0..1], hex[2..3], hex[4..5]].map { |h| h.to_i(16) / 255.0 }
k = 1 - [r, g, b].max
if k == 1
CMYK.new([0, 0, 0, 100])
else
c = (1 - r - k) / (1 - k)
m = (1 - g - k) / (1 - k)
y = (1 - b - k) / (1 - k)
CMYK.new([c, m, y, k].map { |v| (v * 100).round })
end
else
c = (1 - r - k) / (1 - k)
m = (1 - g - k) / (1 - k)
y = (1 - b - k) / (1 - k)
CMYK.new([c, m, y, k].map { |v| (v * 100).round })
RGB.new(hex)
end
else
RGB.new(hex)
end
end
end
68 changes: 68 additions & 0 deletions lib/prawn/svg/css/values_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
module Prawn::SVG::CSS
class ValuesParser
class << self
def parse(values)
result = []

while values
value, remainder = parse_next(values)
break unless value

result << value
values = remainder
end

result
end

private

def parse_next(values)
values = values.strip
return if values.empty?

if (matches = values.match(/\A([a-z-]+)\(\s*(.+)/i))
parse_function_call(matches[1].downcase, matches[2])
else
values.split(/\s+/, 2)
end
end

# Note this does not support space-separated arguments.
# I don't think CSS 2 has any, but in case it does here is the place to add them.
def parse_function_call(name, rest)
arguments = []
in_quote = nil
in_escape = false
current = ''

rest.chars.each.with_index do |char, index|
if in_escape
current << char
in_escape = false
elsif %w[" '].include?(char)
if in_quote == char
in_quote = nil
elsif in_quote.nil?
in_quote = char
else
current << char
end
elsif char == '\\'
in_escape = true
elsif in_quote.nil? && char == ','
arguments << current.strip
current = ''
elsif in_quote.nil? && char == ')'
arguments << current.strip
return [[name, arguments], rest[index + 1..]]
else
current << char
end
end

[rest, nil]
end
end
end
end
14 changes: 10 additions & 4 deletions lib/prawn/svg/elements/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,16 @@ def require_positive_value(*args)
end
end

def extract_element_from_url_id_reference(value, expected_type = nil)
matches = value.strip.match(/\Aurl\(\s*#(\S+)\s*\)\z/i) if value
element = document.elements_by_id[matches[1]] if matches
element if element && (expected_type.nil? || element.name == expected_type)
def extract_element_from_url_id_reference(values, expected_type = nil)
Prawn::SVG::CSS::ValuesParser.parse(values).detect do |value|
case value
in ['url', [url]]
element = document.elements_by_id[url[1..]] if url.start_with?('#')
break element if element && (expected_type.nil? || element.name == expected_type)
else
nil
end
end
end

def href_attribute
Expand Down
16 changes: 16 additions & 0 deletions spec/prawn/svg/css/values_parser_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
require 'spec_helper'

RSpec.describe Prawn::SVG::CSS::ValuesParser do
it 'parses specified values' do
values = 'hello world url("#myid") no-quote(very good) escaping(")\\")ok") rgb( 1,4, 5 )'

expect(described_class.parse(values)).to eq [
'hello',
'world',
['url', ['#myid']],
['no-quote', ['very good']],
['escaping', [')")ok']],
['rgb', %w[1 4 5]]
]
end
end

0 comments on commit 6bd9870

Please sign in to comment.