Skip to content

Commit

Permalink
Basic implementation of linearGradient
Browse files Browse the repository at this point in the history
If you've got Prawn 2.0.3+ (currently unreleased at the time of this commit),
you can get linear gradients, in both units (userSpaceOnUse and
objectBoundingBox).  gradientTransform, spreadMethod and stop-opacity on the
stop tag are all unimplemented.
  • Loading branch information
Roger Nesbitt committed Aug 15, 2015
1 parent fb7de97 commit 5c7fb8a
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 30 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@ prawn-svg supports most but not all of the full SVG 1.1 specification. It curre

- <tt>&lt;style&gt;</tt> plus <tt>id</tt>, <tt>class</tt> and <tt>style</tt> attributes (see CSS section below)

- <tt>&lt;image&gt;</tt> with <tt>http:</tt>, <tt>https:</tt> and <tt>data:image/*;base64</tt> schemes
- <tt>&lt;image&gt;</tt> with <tt>http:</tt>, <tt>https:</tt> and <tt>data:image/\*;base64</tt> schemes

- <tt>&lt;clipPath&gt;</tt>

- <tt>&lt;linearGradient&gt;</tt> but only with Prawn 2.0.3+. gradientTransform, spreadMethod and stop-opacity are
unimplemented.

- attributes/styles: <tt>fill</tt>, <tt>stroke</tt>, <tt>stroke-width</tt>, <tt>stroke-linecap</tt>, <tt>stroke-dasharray</tt>, <tt>opacity</tt>, <tt>fill-opacity</tt>, <tt>stroke-opacity</tt>, <tt>transform</tt>, <tt>clip-path</tt>, <tt>display</tt>

- the <tt>viewBox</tt> attribute on the <tt>&lt;svg&gt;</tt> tag
Expand All @@ -73,7 +76,7 @@ prawn-svg uses the css_parser gem to parse CSS <tt>&lt;style&gt;</tt> blocks. I

## Not supported

prawn-svg does not support external <tt>url()</tt> references, measurements in <tt>en</tt> or <tt>em</tt>, sub-viewports, gradients/patterns or markers.
prawn-svg does not support external <tt>url()</tt> references, measurements in <tt>en</tt> or <tt>em</tt>, sub-viewports, radial gradients, patterns or markers.

## Configuration

Expand Down
56 changes: 43 additions & 13 deletions lib/prawn/svg/color.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
class Prawn::SVG::Color
UnresolvableURLWithNoFallbackError = Class.new(StandardError)
class Hex
attr_reader :value

def initialize(value)
@value = value
end

def ==(other)
value == other.value
end
end

DEFAULT_COLOR = Hex.new("000000")

HTML_COLORS = {
'aliceblue' => 'f0f8ff',
Expand Down Expand Up @@ -153,34 +165,52 @@ class Prawn::SVG::Color

RGB_VALUE_REGEXP = "\s*(-?[0-9.]+%?)\s*"
RGB_REGEXP = /\Argb\(#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP}\)\z/i
URL_REGEXP = /\Aurl\([^)]*\)\z/i
URL_REGEXP = /\Aurl\(([^)]*)\)\z/i

def self.color_to_hex(color_string)
def self.parse(color_string, gradients = nil)
url_specified = false

result = color_string.strip.scan(/([^(\s]+(\([^)]*\))?)/).detect do |color, *_|
components = color_string.to_s.strip.scan(/([^(\s]+(\([^)]*\))?)/)

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

elsif color.match(/\A#[0-9a-f]{6}\z/i)
break color[1..6]
Hex.new(color[1..6])

elsif hex = HTML_COLORS[color.downcase]
break hex
Hex.new(hex)

elsif m = color.match(RGB_REGEXP)
break (1..3).collect do |n|
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
elsif color.match(URL_REGEXP)

Hex.new(hex)

elsif matches = color.match(URL_REGEXP)
url_specified = true
nil # we can't handle these so we find the next thing that matches
url = matches[1]
if url[0] == "#" && gradients && gradient = gradients[url[1..-1]]
gradient
end
end
end

# http://www.w3.org/TR/SVG/painting.html section 11.2
raise UnresolvableURLWithNoFallbackError if result.nil? && 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 unless url_specified

result.compact
end

result
def self.color_to_hex(color)
result = parse(color).detect {|result| result.is_a?(Hex)}
result.value if result
end

protected
Expand Down
3 changes: 2 additions & 1 deletion lib/prawn/svg/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Prawn::SVG::Document
attr_reader :root,
:sizing,
:cache_images, :fallback_font_name,
:css_parser, :elements_by_id
:css_parser, :elements_by_id, :gradients

def initialize(data, bounds, options)
@css_parser = CssParser::Parser.new if CSS_PARSER_LOADED
Expand All @@ -24,6 +24,7 @@ def initialize(data, bounds, options)
@warnings = []
@options = options
@elements_by_id = {}
@gradients = {}
@cache_images = options[:cache_images]
@fallback_font_name = options.fetch(:fallback_font_name, DEFAULT_FALLBACK_FONT_NAME)

Expand Down
3 changes: 2 additions & 1 deletion lib/prawn/svg/elements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module Prawn::SVG::Elements
COMMA_WSP_REGEXP = /(?:\s+,?\s*|,\s*)/
end

%w(base root container style text line polyline polygon circle ellipse rect path use image ignored).each do |filename|
%w(base root container style text line polyline polygon circle ellipse rect path use image gradient ignored).each do |filename|
require "prawn/svg/elements/#{filename}"
end

Expand All @@ -24,6 +24,7 @@ module Prawn::SVG::Elements
path: Prawn::SVG::Elements::Path,
use: Prawn::SVG::Elements::Use,
image: Prawn::SVG::Elements::Image,
linearGradient: Prawn::SVG::Elements::Gradient,
title: Prawn::SVG::Elements::Ignored,
desc: Prawn::SVG::Elements::Ignored,
metadata: Prawn::SVG::Elements::Ignored,
Expand Down
23 changes: 17 additions & 6 deletions lib/prawn/svg/elements/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,26 @@ def parse_fill_and_stroke_attributes_and_call
when 'none'
state[type.to_sym] = false
else
state[type.to_sym] = false
color_attribute = keyword == 'currentcolor' ? 'color' : type
color = @attributes[color_attribute]

begin
hex = Prawn::SVG::Color.color_to_hex(color)
state[type.to_sym] = true
add_call "#{type}_color", hex || '000000'
rescue Prawn::SVG::Color::UnresolvableURLWithNoFallbackError
state[type.to_sym] = false
results = Prawn::SVG::Color.parse(color, document.gradients)

results.each do |result|
case result
when Prawn::SVG::Color::Hex
state[type.to_sym] = true
add_call "#{type}_color", result.value
break
when Prawn::SVG::Elements::Gradient
arguments = result.gradient_arguments(self)
if arguments
state[type.to_sym] = true
add_call "#{type}_gradient", **arguments
break
end
end
end
end

Expand Down
117 changes: 117 additions & 0 deletions lib/prawn/svg/elements/gradient.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base
TAG_NAME_TO_TYPE = {"linearGradient" => :linear}

def parse
# A gradient tag without an ID is inaccessible and can never be used
raise SkipElementQuietly if attributes['id'].nil?

assert_compatible_prawn_version
load_gradient_configuration
load_coordinates
load_stops

document.gradients[attributes['id']] = self

raise SkipElementQuietly # we don't want anything pushed onto the call stack
end

def gradient_arguments(element)
case @units
when :bounding_box
x1, y1, x2, y2 = element.bounding_box
return if y2.nil?

width = x2 - x1
height = y1 - y2

from = [x1 + width * @x1, y1 - height * @y1]
to = [x1 + width * @x2, y1 - height * @y2]

when :user_space
from = [@x1, @y1]
to = [@x2, @y2]
end

{from: from, to: to, stops: @stops}
end

private

def type
TAG_NAME_TO_TYPE.fetch(name)
end

def assert_compatible_prawn_version
if (Prawn::VERSION.split(".").map(&:to_i) <=> [2, 0, 3]) == -1
raise SkipElementError, "Prawn 2.0.3+ must be used if you'd like prawn-svg to render gradients"
end
end

def load_gradient_configuration
@units = attributes["gradientunits"] == 'userSpaceOnUse' ? :user_space : :bounding_box

if transform = attributes["gradienttransform"]
matrix = transform.split(COMMA_WSP_REGEXP).map(&:to_f)
if matrix != [1, 0, 0, 1, 0, 0]
raise SkipElementError, "prawn-svg does not yet support gradients with a non-identity gradientTransform attribute"
end
end

if (spread_method = attributes['spreadmethod']) && spread_method != "pad"
warnings << "prawn-svg only currently supports the 'pad' spreadMethod attribute value"
end
end

def load_coordinates
case @units
when :bounding_box
@x1 = parse_zero_to_one(attributes["x1"], 0)
@y1 = parse_zero_to_one(attributes["y1"], 0)
@x2 = parse_zero_to_one(attributes["x2"], 1)
@y2 = parse_zero_to_one(attributes["y2"], 0)

when :user_space
@x1 = x(attributes["x1"])
@y1 = y(attributes["y1"])
@x2 = x(attributes["x2"])
@y2 = y(attributes["y2"])
end
end

def load_stops
stop_elements = source.elements.map do |child|
element = Prawn::SVG::Elements::Base.new(document, child, [], {})
element.process
element
end.select do |element|
element.name == 'stop' && element.attributes["offset"]
end

@stops = stop_elements.each.with_object([]) do |child, result|
offset = parse_zero_to_one(child.attributes["offset"])

# Offsets must be strictly increasing (SVG 13.2.4)
if result.last && result.last.first > offset
offset = result.last.first
end

if color_hex = Prawn::SVG::Color.color_to_hex(child.attributes["stop-color"])
result << [offset, color_hex]
end
end

raise SkipElementError, "gradient does not have any valid stops" if @stops.empty?

@stops.unshift([0, @stops.first.last]) if @stops.first.first > 0
@stops.push([1, @stops.last.last]) if @stops.last.first < 1
end

def parse_zero_to_one(string, default = 0)
string = string.to_s.strip
return default if string == ""

value = string.to_f
value /= 100.0 if string[-1..-1] == '%'
[0.0, value, 1.0].sort[1]
end
end
35 changes: 28 additions & 7 deletions spec/prawn/svg/color_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require File.dirname(__FILE__) + '/../../spec_helper'

describe Prawn::SVG::Color do
describe :color_to_hex do
describe "::color_to_hex" do
it "converts #xxx to a hex value" do
Prawn::SVG::Color.color_to_hex("#9ab").should == "99aabb"
end
Expand All @@ -27,14 +27,35 @@
expect(Prawn::SVG::Color.color_to_hex("url(#someplace) red")).to eq 'ff0000'
end

it "returns nil if the color doesn't exist" do
expect(Prawn::SVG::Color.color_to_hex("blurble")).to be nil
it "returns black if the color doesn't exist" do
expect(Prawn::SVG::Color.color_to_hex("blurble")).to eq '000000'
end

it "raises UnresolvableURLWithNoFallbackError if there's no fallback after a url()" do
expect {
Prawn::SVG::Color.color_to_hex("url(#someplace)")
}.to raise_error(Prawn::SVG::Color::UnresolvableURLWithNoFallbackError)
it "returns nil if there's no fallback after a url()" do
expect(Prawn::SVG::Color.color_to_hex("url(#someplace)")).to be nil
end
end

describe "::parse" do
let(:gradients) { {"flan" => flan_gradient, "drob" => drob_gradient} }
let(:flan_gradient) { double }
let(:drob_gradient) { double }

it "returns a list of all colors parsed, ignoring impossible or non-existent colors" do
results = Prawn::SVG::Color.parse("url(#nope) url(#flan) blurble green #123", gradients)
expect(results).to eq [
flan_gradient,
Prawn::SVG::Color::Hex.new("008000"),
Prawn::SVG::Color::Hex.new("112233")
]
end

it "appends black to the list if there aren't any url() references" do
results = Prawn::SVG::Color.parse("blurble green", gradients)
expect(results).to eq [
Prawn::SVG::Color::Hex.new("008000"),
Prawn::SVG::Color::Hex.new("000000")
]
end
end
end
Loading

0 comments on commit 5c7fb8a

Please # to comment.