prawn-svg 0.21.0 → 0.22.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +6 -0
  3. data/README.md +11 -4
  4. data/lib/prawn-svg.rb +9 -6
  5. data/lib/prawn/svg/attributes.rb +6 -0
  6. data/lib/prawn/svg/attributes/clip_path.rb +17 -0
  7. data/lib/prawn/svg/attributes/display.rb +5 -0
  8. data/lib/prawn/svg/attributes/font.rb +38 -0
  9. data/lib/prawn/svg/attributes/opacity.rb +15 -0
  10. data/lib/prawn/svg/attributes/stroke.rb +35 -0
  11. data/lib/prawn/svg/attributes/transform.rb +50 -0
  12. data/lib/prawn/svg/calculators/aspect_ratio.rb +1 -1
  13. data/lib/prawn/svg/calculators/document_sizing.rb +2 -2
  14. data/lib/prawn/svg/calculators/pixels.rb +1 -1
  15. data/lib/prawn/svg/color.rb +44 -14
  16. data/lib/prawn/svg/document.rb +6 -5
  17. data/lib/prawn/svg/elements.rb +33 -0
  18. data/lib/prawn/svg/elements/base.rb +228 -0
  19. data/lib/prawn/svg/elements/circle.rb +25 -0
  20. data/lib/prawn/svg/elements/container.rb +15 -0
  21. data/lib/prawn/svg/elements/ellipse.rb +23 -0
  22. data/lib/prawn/svg/elements/gradient.rb +117 -0
  23. data/lib/prawn/svg/elements/ignored.rb +5 -0
  24. data/lib/prawn/svg/elements/image.rb +85 -0
  25. data/lib/prawn/svg/elements/line.rb +16 -0
  26. data/lib/prawn/svg/elements/path.rb +405 -0
  27. data/lib/prawn/svg/elements/polygon.rb +17 -0
  28. data/lib/prawn/svg/elements/polyline.rb +22 -0
  29. data/lib/prawn/svg/elements/rect.rb +33 -0
  30. data/lib/prawn/svg/elements/root.rb +9 -0
  31. data/lib/prawn/svg/elements/style.rb +10 -0
  32. data/lib/prawn/svg/elements/text.rb +87 -0
  33. data/lib/prawn/svg/elements/use.rb +29 -0
  34. data/lib/prawn/svg/extension.rb +2 -2
  35. data/lib/prawn/svg/font.rb +3 -3
  36. data/lib/prawn/svg/interface.rb +12 -5
  37. data/lib/prawn/svg/url_loader.rb +1 -1
  38. data/lib/prawn/svg/version.rb +2 -2
  39. data/prawn-svg.gemspec +3 -3
  40. data/spec/integration_spec.rb +59 -2
  41. data/spec/prawn/svg/attributes/font_spec.rb +49 -0
  42. data/spec/prawn/svg/attributes/transform_spec.rb +56 -0
  43. data/spec/prawn/svg/calculators/aspect_ratio_spec.rb +2 -2
  44. data/spec/prawn/svg/calculators/document_sizing_spec.rb +3 -3
  45. data/spec/prawn/svg/color_spec.rb +36 -15
  46. data/spec/prawn/svg/document_spec.rb +4 -4
  47. data/spec/prawn/svg/elements/base_spec.rb +125 -0
  48. data/spec/prawn/svg/elements/gradient_spec.rb +61 -0
  49. data/spec/prawn/svg/elements/path_spec.rb +123 -0
  50. data/spec/prawn/svg/elements/style_spec.rb +23 -0
  51. data/spec/prawn/svg/{parser → elements}/text_spec.rb +7 -8
  52. data/spec/prawn/svg/font_spec.rb +12 -12
  53. data/spec/prawn/svg/interface_spec.rb +7 -7
  54. data/spec/prawn/svg/url_loader_spec.rb +2 -2
  55. data/spec/sample_svg/gradients.svg +40 -0
  56. data/spec/sample_svg/rect02.svg +8 -11
  57. data/spec/spec_helper.rb +1 -1
  58. metadata +46 -18
  59. data/lib/prawn/svg/element.rb +0 -304
  60. data/lib/prawn/svg/parser.rb +0 -268
  61. data/lib/prawn/svg/parser/image.rb +0 -81
  62. data/lib/prawn/svg/parser/path.rb +0 -392
  63. data/lib/prawn/svg/parser/text.rb +0 -80
  64. data/spec/prawn/svg/element_spec.rb +0 -127
  65. data/spec/prawn/svg/parser/path_spec.rb +0 -89
  66. data/spec/prawn/svg/parser_spec.rb +0 -55
@@ -0,0 +1,228 @@
1
+ class Prawn::SVG::Elements::Base
2
+ extend Forwardable
3
+
4
+ include Prawn::SVG::Attributes::Transform
5
+ include Prawn::SVG::Attributes::Opacity
6
+ include Prawn::SVG::Attributes::ClipPath
7
+ include Prawn::SVG::Attributes::Stroke
8
+ include Prawn::SVG::Attributes::Font
9
+ include Prawn::SVG::Attributes::Display
10
+
11
+ COMMA_WSP_REGEXP = Prawn::SVG::Elements::COMMA_WSP_REGEXP
12
+
13
+ SkipElementQuietly = Class.new(StandardError)
14
+ SkipElementError = Class.new(StandardError)
15
+ MissingAttributesError = Class.new(SkipElementError)
16
+
17
+ attr_reader :document, :source, :parent_calls, :base_calls, :state, :attributes
18
+ attr_accessor :calls
19
+
20
+ def_delegators :@document, :x, :y, :distance, :points, :warnings
21
+
22
+ def initialize(document, source, parent_calls, state)
23
+ @document = document
24
+ @source = source
25
+ @parent_calls = parent_calls
26
+ @state = state
27
+ @base_calls = @calls = []
28
+
29
+ if id = source.attributes["id"]
30
+ document.elements_by_id[id] = self
31
+ end
32
+ end
33
+
34
+ def process
35
+ combine_attributes_and_style_declarations
36
+ parse
37
+
38
+ apply_calls_from_standard_attributes
39
+ apply
40
+
41
+ append_calls_to_parent
42
+ rescue SkipElementQuietly
43
+ rescue SkipElementError => e
44
+ @document.warnings << e.message
45
+ end
46
+
47
+ def name
48
+ @name ||= source.name
49
+ end
50
+
51
+ protected
52
+
53
+ def parse
54
+ end
55
+
56
+ def apply
57
+ end
58
+
59
+ def bounding_box
60
+ end
61
+
62
+ def container?
63
+ false
64
+ end
65
+
66
+ def add_call(name, *arguments)
67
+ @calls << [name.to_s, arguments, []]
68
+ end
69
+
70
+ def add_call_and_enter(name, *arguments)
71
+ @calls << [name.to_s, arguments, []]
72
+ @calls = @calls.last.last
73
+ end
74
+
75
+ def append_calls_to_parent
76
+ @parent_calls.concat(@base_calls)
77
+ end
78
+
79
+ def add_calls_from_element(other)
80
+ @calls.concat other.base_calls
81
+ end
82
+
83
+ def process_child_elements
84
+ source.elements.each do |elem|
85
+ if element_class = Prawn::SVG::Elements::TAG_CLASS_MAPPING[elem.name.to_sym]
86
+ add_call "save"
87
+
88
+ child = element_class.new(@document, elem, @calls, @state.dup)
89
+ child.process
90
+
91
+ add_call "restore"
92
+ else
93
+ @document.warnings << "Unknown tag '#{elem.name}'; ignoring"
94
+ end
95
+ end
96
+ end
97
+
98
+ def apply_calls_from_standard_attributes
99
+ parse_transform_attribute_and_call
100
+ parse_opacity_attributes_and_call
101
+ parse_clip_path_attribute_and_call
102
+ draw_types = parse_fill_and_stroke_attributes_and_call
103
+ parse_stroke_attributes_and_call
104
+ parse_font_attributes_and_call
105
+ parse_display_attribute
106
+ apply_drawing_call(draw_types)
107
+ end
108
+
109
+ def apply_drawing_call(draw_types)
110
+ if !@state[:disable_drawing] && !container?
111
+ if draw_types.empty? || @state[:display] == "none"
112
+ add_call_and_enter("end_path")
113
+ else
114
+ add_call_and_enter(draw_types.join("_and_"))
115
+ end
116
+ end
117
+ end
118
+
119
+ def parse_fill_and_stroke_attributes_and_call
120
+ ["fill", "stroke"].select do |type|
121
+ case keyword = attribute_value_as_keyword(type)
122
+ when nil
123
+ when 'inherit'
124
+ when 'none'
125
+ state[type.to_sym] = false
126
+ else
127
+ state[type.to_sym] = false
128
+ color_attribute = keyword == 'currentcolor' ? 'color' : type
129
+ color = @attributes[color_attribute]
130
+
131
+ results = Prawn::SVG::Color.parse(color, document.gradients)
132
+
133
+ results.each do |result|
134
+ case result
135
+ when Prawn::SVG::Color::Hex
136
+ state[type.to_sym] = true
137
+ add_call "#{type}_color", result.value
138
+ break
139
+ when Prawn::SVG::Elements::Gradient
140
+ arguments = result.gradient_arguments(self)
141
+ if arguments
142
+ state[type.to_sym] = true
143
+ add_call "#{type}_gradient", **arguments
144
+ break
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ state[type.to_sym]
151
+ end
152
+ end
153
+
154
+ def clamp(value, min_value, max_value)
155
+ [[value, min_value].max, max_value].min
156
+ end
157
+
158
+ def combine_attributes_and_style_declarations
159
+ if @document && @document.css_parser
160
+ tag_style = @document.css_parser.find_by_selector(source.name)
161
+ id_style = @document.css_parser.find_by_selector("##{source.attributes["id"]}") if source.attributes["id"]
162
+
163
+ if classes = source.attributes["class"]
164
+ class_styles = classes.strip.split(/\s+/).collect do |class_name|
165
+ @document.css_parser.find_by_selector(".#{class_name}")
166
+ end
167
+ end
168
+
169
+ element_style = source.attributes['style']
170
+
171
+ style = [tag_style, class_styles, id_style, element_style].flatten.collect do |s|
172
+ s.nil? || s.strip == "" ? "" : "#{s}#{";" unless s.match(/;\s*\z/)}"
173
+ end.join
174
+ else
175
+ style = source.attributes['style'] || ""
176
+ end
177
+
178
+ @attributes = parse_css_declarations(style)
179
+
180
+ source.attributes.each do |name, value|
181
+ name = name.downcase # TODO : this is incorrect; attributes are case sensitive
182
+ @attributes[name] = value unless @attributes[name]
183
+ end
184
+ end
185
+
186
+ def parse_css_declarations(declarations)
187
+ # copied from css_parser
188
+ declarations.gsub!(/(^[\s]*)|([\s]*$)/, '')
189
+
190
+ output = {}
191
+ declarations.split(/[\;$]+/m).each do |decs|
192
+ if matches = decs.match(/\s*(.[^:]*)\s*\:\s*(.[^;]*)\s*(;|\Z)/i)
193
+ property, value, _ = matches.captures
194
+ output[property.downcase] = value
195
+ end
196
+ end
197
+ output
198
+ end
199
+
200
+ def attribute_value_as_keyword(name)
201
+ if value = @attributes[name]
202
+ value.strip.downcase
203
+ end
204
+ end
205
+
206
+ def parse_points(points_string)
207
+ points_string.
208
+ to_s.
209
+ strip.
210
+ gsub(/(\d)-(\d)/, '\1 -\2').
211
+ split(COMMA_WSP_REGEXP).
212
+ each_slice(2).
213
+ map {|x, y| [x(x), y(y)]}
214
+ end
215
+
216
+ def require_attributes(*names)
217
+ missing_attrs = names - attributes.keys
218
+ if missing_attrs.any?
219
+ raise MissingAttributesError, "Must have attributes #{missing_attrs.join(", ")} on tag #{name}; skipping tag"
220
+ end
221
+ end
222
+
223
+ def require_positive_value(*args)
224
+ if args.any? {|arg| arg.nil? || arg <= 0}
225
+ raise SkipElementError, "Invalid attributes on tag #{name}; skipping tag"
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,25 @@
1
+ class Prawn::SVG::Elements::Circle < Prawn::SVG::Elements::Base
2
+ USE_NEW_CIRCLE_CALL = Prawn::Document.instance_methods.include?(:circle)
3
+
4
+ def parse
5
+ require_attributes 'r'
6
+
7
+ @x = x(attributes['cx'] || "0")
8
+ @y = y(attributes['cy'] || "0")
9
+ @r = distance(attributes['r'])
10
+
11
+ require_positive_value @r
12
+ end
13
+
14
+ def apply
15
+ if USE_NEW_CIRCLE_CALL
16
+ add_call "circle", [@x, @y], @r
17
+ else
18
+ add_call "circle_at", [@x, @y], radius: @r
19
+ end
20
+ end
21
+
22
+ def bounding_box
23
+ [@x - @r, @y + @r, @x + @r, @y - @r]
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ class Prawn::SVG::Elements::Container < Prawn::SVG::Elements::Base
2
+ def parse
3
+ state[:disable_drawing] = true if name == "clipPath"
4
+ end
5
+
6
+ def apply
7
+ process_child_elements
8
+
9
+ raise SkipElementQuietly if %w(symbol defs clipPath).include?(name)
10
+ end
11
+
12
+ def container?
13
+ true
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ class Prawn::SVG::Elements::Ellipse < Prawn::SVG::Elements::Base
2
+ USE_NEW_ELLIPSE_CALL = Prawn::Document.instance_methods.include?(:ellipse)
3
+
4
+ def parse
5
+ require_attributes 'rx', 'ry'
6
+
7
+ @x = x(attributes['cx'] || "0")
8
+ @y = y(attributes['cy'] || "0")
9
+ @rx = distance(attributes['rx'], :x)
10
+ @ry = distance(attributes['ry'], :y)
11
+
12
+ require_positive_value @rx, @ry
13
+ end
14
+
15
+ def apply
16
+ add_call USE_NEW_ELLIPSE_CALL ? "ellipse" : "ellipse_at", [@x, @y], @rx, @ry
17
+ end
18
+
19
+ def bounding_box
20
+ [@x - @rx, @y + @ry, @x + @rx, @y - @ry]
21
+ end
22
+ end
23
+
@@ -0,0 +1,117 @@
1
+ class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base
2
+ TAG_NAME_TO_TYPE = {"linearGradient" => :linear}
3
+
4
+ def parse
5
+ # A gradient tag without an ID is inaccessible and can never be used
6
+ raise SkipElementQuietly if attributes['id'].nil?
7
+
8
+ assert_compatible_prawn_version
9
+ load_gradient_configuration
10
+ load_coordinates
11
+ load_stops
12
+
13
+ document.gradients[attributes['id']] = self
14
+
15
+ raise SkipElementQuietly # we don't want anything pushed onto the call stack
16
+ end
17
+
18
+ def gradient_arguments(element)
19
+ case @units
20
+ when :bounding_box
21
+ x1, y1, x2, y2 = element.bounding_box
22
+ return if y2.nil?
23
+
24
+ width = x2 - x1
25
+ height = y1 - y2
26
+
27
+ from = [x1 + width * @x1, y1 - height * @y1]
28
+ to = [x1 + width * @x2, y1 - height * @y2]
29
+
30
+ when :user_space
31
+ from = [@x1, @y1]
32
+ to = [@x2, @y2]
33
+ end
34
+
35
+ {from: from, to: to, stops: @stops}
36
+ end
37
+
38
+ private
39
+
40
+ def type
41
+ TAG_NAME_TO_TYPE.fetch(name)
42
+ end
43
+
44
+ def assert_compatible_prawn_version
45
+ if (Prawn::VERSION.split(".").map(&:to_i) <=> [2, 0, 4]) == -1
46
+ raise SkipElementError, "Prawn 2.0.4+ must be used if you'd like prawn-svg to render gradients"
47
+ end
48
+ end
49
+
50
+ def load_gradient_configuration
51
+ @units = attributes["gradientunits"] == 'userSpaceOnUse' ? :user_space : :bounding_box
52
+
53
+ if transform = attributes["gradienttransform"]
54
+ matrix = transform.split(COMMA_WSP_REGEXP).map(&:to_f)
55
+ if matrix != [1, 0, 0, 1, 0, 0]
56
+ raise SkipElementError, "prawn-svg does not yet support gradients with a non-identity gradientTransform attribute"
57
+ end
58
+ end
59
+
60
+ if (spread_method = attributes['spreadmethod']) && spread_method != "pad"
61
+ warnings << "prawn-svg only currently supports the 'pad' spreadMethod attribute value"
62
+ end
63
+ end
64
+
65
+ def load_coordinates
66
+ case @units
67
+ when :bounding_box
68
+ @x1 = parse_zero_to_one(attributes["x1"], 0)
69
+ @y1 = parse_zero_to_one(attributes["y1"], 0)
70
+ @x2 = parse_zero_to_one(attributes["x2"], 1)
71
+ @y2 = parse_zero_to_one(attributes["y2"], 0)
72
+
73
+ when :user_space
74
+ @x1 = x(attributes["x1"])
75
+ @y1 = y(attributes["y1"])
76
+ @x2 = x(attributes["x2"])
77
+ @y2 = y(attributes["y2"])
78
+ end
79
+ end
80
+
81
+ def load_stops
82
+ stop_elements = source.elements.map do |child|
83
+ element = Prawn::SVG::Elements::Base.new(document, child, [], {})
84
+ element.process
85
+ element
86
+ end.select do |element|
87
+ element.name == 'stop' && element.attributes["offset"]
88
+ end
89
+
90
+ @stops = stop_elements.each.with_object([]) do |child, result|
91
+ offset = parse_zero_to_one(child.attributes["offset"])
92
+
93
+ # Offsets must be strictly increasing (SVG 13.2.4)
94
+ if result.last && result.last.first > offset
95
+ offset = result.last.first
96
+ end
97
+
98
+ if color_hex = Prawn::SVG::Color.color_to_hex(child.attributes["stop-color"])
99
+ result << [offset, color_hex]
100
+ end
101
+ end
102
+
103
+ raise SkipElementError, "gradient does not have any valid stops" if @stops.empty?
104
+
105
+ @stops.unshift([0, @stops.first.last]) if @stops.first.first > 0
106
+ @stops.push([1, @stops.last.last]) if @stops.last.first < 1
107
+ end
108
+
109
+ def parse_zero_to_one(string, default = 0)
110
+ string = string.to_s.strip
111
+ return default if string == ""
112
+
113
+ value = string.to_f
114
+ value /= 100.0 if string[-1..-1] == '%'
115
+ [0.0, value, 1.0].sort[1]
116
+ end
117
+ end
@@ -0,0 +1,5 @@
1
+ class Prawn::SVG::Elements::Ignored < Prawn::SVG::Elements::Base
2
+ def parse
3
+ raise SkipElementQuietly
4
+ end
5
+ end
@@ -0,0 +1,85 @@
1
+ class Prawn::SVG::Elements::Image < Prawn::SVG::Elements::Base
2
+ class FakeIO
3
+ def initialize(data)
4
+ @data = data
5
+ end
6
+ def read
7
+ @data
8
+ end
9
+ def rewind
10
+ end
11
+ end
12
+
13
+ def parse
14
+ require_attributes 'width', 'height'
15
+
16
+ raise SkipElementQuietly if state[:display] == "none"
17
+
18
+ @url = attributes['xlink:href'] || attributes['href']
19
+ if @url.nil?
20
+ raise SkipElementError, "image tag must have an xlink:href"
21
+ end
22
+
23
+ if !@document.url_loader.valid?(@url)
24
+ raise SkipElementError, "image tag xlink:href attribute must use http, https or data scheme"
25
+ end
26
+
27
+ x = x(attributes['x'] || 0)
28
+ y = y(attributes['y'] || 0)
29
+ width = distance(attributes['width'])
30
+ height = distance(attributes['height'])
31
+
32
+ raise SkipElementQuietly if width.zero? || height.zero?
33
+ require_positive_value width, height
34
+
35
+ @image = begin
36
+ @document.url_loader.load(@url)
37
+ rescue => e
38
+ raise SkipElementError, "Error retrieving URL #{@url}: #{e.message}"
39
+ end
40
+
41
+ @aspect = Prawn::SVG::Calculators::AspectRatio.new(attributes['preserveAspectRatio'], [width, height], image_dimensions(@image))
42
+
43
+ @clip_x = x
44
+ @clip_y = y
45
+ @clip_width = width
46
+ @clip_height = height
47
+
48
+ @width = @aspect.width
49
+ @height = @aspect.height
50
+ @x = x + @aspect.x
51
+ @y = y - @aspect.y
52
+ end
53
+
54
+ def apply
55
+ if @aspect.slice?
56
+ add_call "save"
57
+ add_call "rectangle", [@clip_x, @clip_y], @clip_width, @clip_height
58
+ add_call "clip"
59
+ end
60
+
61
+ options = {:width => @width, :height => @height, :at => [@x, @y]}
62
+
63
+ add_call "image", FakeIO.new(@image), options
64
+ add_call "restore" if @aspect.slice?
65
+ end
66
+
67
+ def bounding_box
68
+ [@x, @y, @x + @width, @y - @height]
69
+ end
70
+
71
+ protected
72
+
73
+ def image_dimensions(data)
74
+ handler = if data[0, 3].unpack("C*") == [255, 216, 255]
75
+ Prawn::Images::JPG
76
+ elsif data[0, 8].unpack("C*") == [137, 80, 78, 71, 13, 10, 26, 10]
77
+ Prawn::Images::PNG
78
+ else
79
+ raise SkipElementError, "Unsupported image type supplied to image tag; Prawn only supports JPG and PNG"
80
+ end
81
+
82
+ image = handler.new(data)
83
+ [image.width.to_f, image.height.to_f]
84
+ end
85
+ end