prawn-svg 0.21.0 → 0.22.1

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.
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