prawn-svg 0.9.1.6 → 0.9.1.7
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.
- data/lib/prawn-svg.rb +5 -3
- data/lib/prawn/svg/extension.rb +24 -0
- data/lib/prawn/svg/interface.rb +87 -0
- data/lib/prawn/svg/parser.rb +368 -362
- data/lib/prawn/svg/parser/path.rb +184 -0
- metadata +6 -6
- data/lib/prawn/svg/path.rb +0 -180
- data/lib/prawn/svg/svg.rb +0 -83
- data/lib/prawn/svg_document.rb +0 -22
data/lib/prawn-svg.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
require 'prawn'
|
2
|
-
require 'prawn/
|
3
|
-
require 'prawn/svg/
|
2
|
+
require 'prawn/svg/extension'
|
3
|
+
require 'prawn/svg/interface'
|
4
4
|
require 'prawn/svg/parser'
|
5
|
-
require 'prawn/svg/path'
|
5
|
+
require 'prawn/svg/parser/path'
|
6
|
+
|
7
|
+
Prawn::Document.extensions << Prawn::Svg::Extension
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Prawn
|
2
|
+
module Svg
|
3
|
+
module Extension
|
4
|
+
#
|
5
|
+
# Draws an SVG document into the PDF.
|
6
|
+
#
|
7
|
+
# +options+ must contain the key :at, which takes a tuple of x and y co-ordinates.
|
8
|
+
#
|
9
|
+
# +options+ can optionally contain the key :width or :height. If both are
|
10
|
+
# specified, only :width will be used. If neither are specified, the resolution
|
11
|
+
# given in the SVG will be used.
|
12
|
+
#
|
13
|
+
# Example usage:
|
14
|
+
#
|
15
|
+
# svg IO.read("example.svg"), :at => [100, 300], :width => 600
|
16
|
+
#
|
17
|
+
def svg(data, options={})
|
18
|
+
svg = Prawn::Svg::Interface.new(data, self, options)
|
19
|
+
svg.draw
|
20
|
+
{:warnings => svg.parser_warnings, :width => svg.parser.width, :height => svg.parser.height}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
#
|
2
|
+
# Prawn::Svg::Interface makes a Prawn::Svg::Parser instance, uses that object to parse the supplied
|
3
|
+
# SVG into Prawn-compatible method calls, and then calls the Prawn methods.
|
4
|
+
#
|
5
|
+
module Prawn
|
6
|
+
module Svg
|
7
|
+
class Interface
|
8
|
+
DEFAULT_FONT_PATHS = ["/Library/Fonts", "/usr/share/fonts/truetype/**"]
|
9
|
+
|
10
|
+
@font_path = []
|
11
|
+
DEFAULT_FONT_PATHS.each {|path| @font_path << path if File.exists?(path)}
|
12
|
+
|
13
|
+
class << self; attr_accessor :font_path; end
|
14
|
+
|
15
|
+
attr_reader :data, :prawn, :parser, :options
|
16
|
+
|
17
|
+
# An +Array+ of warnings that occurred while parsing the SVG data. If this array is non-empty,
|
18
|
+
# it's likely that the SVG failed to render correctly.
|
19
|
+
attr_reader :parser_warnings
|
20
|
+
|
21
|
+
#
|
22
|
+
# Creates a Prawn::Svg object.
|
23
|
+
#
|
24
|
+
# +data+ is the SVG data to convert. +prawn+ is your Prawn::Document object.
|
25
|
+
#
|
26
|
+
# +options+ must contain the key :at, which takes a tuple of x and y co-ordinates.
|
27
|
+
#
|
28
|
+
# +options+ can optionally contain the key :width or :height. If both are
|
29
|
+
# specified, only :width will be used.
|
30
|
+
#
|
31
|
+
def initialize(data, prawn, options)
|
32
|
+
@data = data
|
33
|
+
@prawn = prawn
|
34
|
+
@options = options
|
35
|
+
|
36
|
+
@options[:at] or raise "options[:at] must be specified"
|
37
|
+
|
38
|
+
@parser = Parser.new(data, [prawn.bounds.width, prawn.bounds.height], options)
|
39
|
+
@parser_warnings = @parser.warnings
|
40
|
+
end
|
41
|
+
|
42
|
+
#
|
43
|
+
# Draws the SVG to the Prawn::Document object.
|
44
|
+
#
|
45
|
+
def draw
|
46
|
+
prawn.bounding_box(@options[:at], :width => @parser.width, :height => @parser.height) do
|
47
|
+
prawn.save_graphics_state do
|
48
|
+
proc_creator(prawn, @parser.parse).call
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
private
|
55
|
+
def proc_creator(prawn, calls)
|
56
|
+
Proc.new {issue_prawn_command(prawn, calls)}
|
57
|
+
end
|
58
|
+
|
59
|
+
def issue_prawn_command(prawn, calls)
|
60
|
+
calls.each do |call, arguments, children|
|
61
|
+
if rewrite_call_arguments(prawn, call, arguments) == false
|
62
|
+
issue_prawn_command(prawn, children) if children.any?
|
63
|
+
else
|
64
|
+
if children.empty?
|
65
|
+
prawn.send(call, *arguments)
|
66
|
+
else
|
67
|
+
prawn.send(call, *arguments, &proc_creator(prawn, children))
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def rewrite_call_arguments(prawn, call, arguments)
|
74
|
+
case call
|
75
|
+
when 'text_box'
|
76
|
+
if (anchor = arguments.last.delete(:text_anchor)) && %w(middle end).include?(anchor)
|
77
|
+
width = prawn.width_of(*arguments)
|
78
|
+
width /= 2 if anchor == 'middle'
|
79
|
+
arguments.last[:at][0] -= width
|
80
|
+
end
|
81
|
+
|
82
|
+
arguments.last[:at][1] += prawn.height_of(*arguments) / 3 * 2
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/lib/prawn/svg/parser.rb
CHANGED
@@ -11,441 +11,447 @@ require 'rexml/document'
|
|
11
11
|
# prawn specifically - this might be useful if you want to take this code and use it to convert
|
12
12
|
# SVG to another format.
|
13
13
|
#
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
14
|
+
module Prawn
|
15
|
+
module Svg
|
16
|
+
class Parser
|
17
|
+
begin
|
18
|
+
require 'css_parser'
|
19
|
+
CSS_PARSER_LOADED = true
|
20
|
+
rescue LoadError
|
21
|
+
CSS_PARSER_LOADED = false
|
22
|
+
end
|
21
23
|
|
22
|
-
|
24
|
+
include Prawn::Measurements
|
23
25
|
|
24
|
-
|
26
|
+
attr_reader :width, :height
|
25
27
|
|
26
|
-
|
27
|
-
|
28
|
+
# An +Array+ of warnings that occurred while parsing the SVG data.
|
29
|
+
attr_reader :warnings
|
28
30
|
|
29
|
-
|
30
|
-
|
31
|
+
# The scaling factor, as determined by the :width or :height options.
|
32
|
+
attr_accessor :scale
|
31
33
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
34
|
+
#
|
35
|
+
# Construct a Parser object.
|
36
|
+
#
|
37
|
+
# The +data+ argument is SVG data.
|
38
|
+
#
|
39
|
+
# +bounds+ is a tuple [width, height] that specifies the bounds of the drawing space in points.
|
40
|
+
#
|
41
|
+
# +options+ can optionally contain
|
42
|
+
# the key :width or :height. If both are specified, only :width will be used.
|
43
|
+
#
|
44
|
+
def initialize(data, bounds, options)
|
45
|
+
@data = data
|
46
|
+
@bounds = bounds
|
47
|
+
@options = options
|
48
|
+
@warnings = []
|
49
|
+
@css_parser = CssParser::Parser.new if CSS_PARSER_LOADED
|
48
50
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
51
|
+
if data
|
52
|
+
parse_document
|
53
|
+
calculate_dimensions
|
54
|
+
end
|
55
|
+
end
|
54
56
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
57
|
+
#
|
58
|
+
# Parse the SVG data and return a call tree. The returned +Array+ is in the format:
|
59
|
+
#
|
60
|
+
# [
|
61
|
+
# ['prawn_method_name', ['argument1', 'argument2'], []],
|
62
|
+
# ['method_that_takes_a_block', ['argument1', 'argument2'], [
|
63
|
+
# ['method_called_inside_block', ['argument'], []]
|
64
|
+
# ]
|
65
|
+
# ]
|
66
|
+
#
|
67
|
+
def parse
|
68
|
+
@warnings = []
|
69
|
+
calls = []
|
70
|
+
parse_element(@root, calls, {})
|
71
|
+
calls
|
72
|
+
end
|
69
73
|
|
70
74
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
+
private
|
76
|
+
def parse_document
|
77
|
+
@root = REXML::Document.new(@data).root
|
78
|
+
@actual_width, @actual_height = @bounds # set this first so % width/heights can be used
|
75
79
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
80
|
+
if vb = @root.attributes['viewBox']
|
81
|
+
x1, y1, x2, y2 = vb.strip.split(/\s+/)
|
82
|
+
@x_offset, @y_offset = [x1.to_f, y1.to_f]
|
83
|
+
@actual_width, @actual_height = [x2.to_f - x1.to_f, y2.to_f - y1.to_f]
|
84
|
+
else
|
85
|
+
@x_offset, @y_offset = [0, 0]
|
86
|
+
@actual_width = points(@root.attributes['width'], :x)
|
87
|
+
@actual_height = points(@root.attributes['height'], :y)
|
88
|
+
end
|
89
|
+
end
|
86
90
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
91
|
+
REQUIRED_ATTRIBUTES = {
|
92
|
+
"line" => %w(x1 y1 x2 y2),
|
93
|
+
"polyline" => %w(points),
|
94
|
+
"polygon" => %w(points),
|
95
|
+
"circle" => %w(r),
|
96
|
+
"ellipse" => %w(rx ry),
|
97
|
+
"rect" => %w(x y width height),
|
98
|
+
"path" => %w(d)
|
99
|
+
}
|
96
100
|
|
97
|
-
|
98
|
-
|
99
|
-
|
101
|
+
def parse_element(element, calls, state)
|
102
|
+
attrs = element.attributes
|
103
|
+
calls, style_attrs = apply_styles(element, calls, state)
|
100
104
|
|
101
|
-
|
102
|
-
|
103
|
-
|
105
|
+
if required_attributes = REQUIRED_ATTRIBUTES[element.name]
|
106
|
+
return unless check_attrs_present(element, required_attributes)
|
107
|
+
end
|
104
108
|
|
105
|
-
|
106
|
-
|
107
|
-
|
109
|
+
case element.name
|
110
|
+
when 'title', 'desc'
|
111
|
+
# ignore
|
108
112
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
+
when 'g', 'svg'
|
114
|
+
element.elements.each do |child|
|
115
|
+
parse_element(child, calls, state.dup)
|
116
|
+
end
|
113
117
|
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
118
|
+
when 'defs'
|
119
|
+
# Pass calls as a blank array so that nothing under this tag can be added to our call tree.
|
120
|
+
element.elements.each do |child|
|
121
|
+
parse_element(child, [], state.dup.merge(:display => false))
|
122
|
+
end
|
119
123
|
|
120
|
-
|
121
|
-
|
124
|
+
when 'style'
|
125
|
+
load_css_styles(element)
|
122
126
|
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
127
|
+
when 'text'
|
128
|
+
# Very primitive support for font-family; it won't work in most cases because
|
129
|
+
# PDF only has a few built-in fonts, and they're not the same as the names
|
130
|
+
# used typically with the web fonts.
|
131
|
+
if font_family = style_attrs["font-family"]
|
132
|
+
if font_family != "" && pdf_font = map_font_family_to_pdf_font(font_family)
|
133
|
+
calls << ['font', [pdf_font], []]
|
134
|
+
calls = calls.last.last
|
135
|
+
else
|
136
|
+
@warnings << "#{font_family} is not a known font."
|
137
|
+
end
|
138
|
+
end
|
135
139
|
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
+
opts = {:at => [x(attrs['x']), y(attrs['y'])]}
|
141
|
+
if size = style_attrs['font-size']
|
142
|
+
opts[:size] = size.to_f * @scale
|
143
|
+
end
|
140
144
|
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
145
|
+
# This is not a prawn option but we can't work out how to render it here -
|
146
|
+
# it's handled by Svg#rewrite_call_arguments
|
147
|
+
if anchor = style_attrs['text-anchor']
|
148
|
+
opts[:text_anchor] = anchor
|
149
|
+
end
|
146
150
|
|
147
|
-
|
151
|
+
calls << ['text_box', [element.text, opts], []]
|
148
152
|
|
149
|
-
|
150
|
-
|
153
|
+
when 'line'
|
154
|
+
calls << ['line', [x(attrs['x1']), y(attrs['y1']), x(attrs['x2']), y(attrs['y2'])], []]
|
151
155
|
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
156
|
+
when 'polyline'
|
157
|
+
points = attrs['points'].split(/\s+/)
|
158
|
+
return unless base_point = points.shift
|
159
|
+
x, y = base_point.split(",")
|
160
|
+
calls << ['move_to', [x(x), y(y)], []]
|
161
|
+
calls << ['stroke', [], []]
|
162
|
+
calls = calls.last.last
|
163
|
+
points.each do |point|
|
164
|
+
x, y = point.split(",")
|
165
|
+
calls << ["line_to", [x(x), y(y)], []]
|
166
|
+
end
|
163
167
|
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
168
|
+
when 'polygon'
|
169
|
+
points = attrs['points'].split(/\s+/).collect do |point|
|
170
|
+
x, y = point.split(",")
|
171
|
+
[x(x), y(y)]
|
172
|
+
end
|
173
|
+
calls << ["polygon", points, []]
|
170
174
|
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
+
when 'circle'
|
176
|
+
calls << ["circle_at",
|
177
|
+
[[x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], {:radius => distance(attrs['r'])}],
|
178
|
+
[]]
|
175
179
|
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
+
when 'ellipse'
|
181
|
+
calls << ["ellipse_at",
|
182
|
+
[[x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], distance(attrs['rx']), distance(attrs['ry'])],
|
183
|
+
[]]
|
180
184
|
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
185
|
+
when 'rect'
|
186
|
+
radius = distance(attrs['rx'] || attrs['ry'])
|
187
|
+
args = [[x(attrs['x']), y(attrs['y'])], distance(attrs['width']), distance(attrs['height'])]
|
188
|
+
if radius
|
189
|
+
# n.b. does not support both rx and ry being specified with different values
|
190
|
+
calls << ["rounded_rectangle", args + [radius], []]
|
191
|
+
else
|
192
|
+
calls << ["rectangle", args, []]
|
193
|
+
end
|
190
194
|
|
191
|
-
|
192
|
-
|
195
|
+
when 'path'
|
196
|
+
@svg_path ||= Path.new
|
193
197
|
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
198
|
+
begin
|
199
|
+
commands = @svg_path.parse(attrs['d'])
|
200
|
+
rescue Prawn::Svg::Parser::Path::InvalidError => e
|
201
|
+
commands = []
|
202
|
+
@warnings << e.message
|
203
|
+
end
|
200
204
|
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
205
|
+
commands.each do |command, args|
|
206
|
+
point_to = [x(args[0]), y(args[1])]
|
207
|
+
if command == 'curve_to'
|
208
|
+
bounds = [[x(args[2]), y(args[3])], [x(args[4]), y(args[5])]]
|
209
|
+
calls << [command, [point_to, {:bounds => bounds}], []]
|
210
|
+
else
|
211
|
+
calls << [command, point_to, []]
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
else
|
216
|
+
@warnings << "Unknown tag '#{element.name}'; ignoring"
|
208
217
|
end
|
209
218
|
end
|
210
|
-
|
211
|
-
else
|
212
|
-
@warnings << "Unknown tag '#{element.name}'; ignoring"
|
213
|
-
end
|
214
|
-
end
|
215
219
|
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
220
|
+
def load_css_styles(element)
|
221
|
+
if @css_parser
|
222
|
+
data = if element.cdatas.any?
|
223
|
+
element.cdatas.collect(&:to_s).join
|
224
|
+
else
|
225
|
+
element.text
|
226
|
+
end
|
223
227
|
|
224
|
-
|
225
|
-
|
226
|
-
|
228
|
+
@css_parser.add_block!(data)
|
229
|
+
end
|
230
|
+
end
|
227
231
|
|
228
|
-
|
229
|
-
|
230
|
-
|
232
|
+
def parse_css_declarations(declarations)
|
233
|
+
# copied from css_parser
|
234
|
+
declarations.gsub!(/(^[\s]*)|([\s]*$)/, '')
|
231
235
|
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
236
|
+
output = {}
|
237
|
+
declarations.split(/[\;$]+/m).each do |decs|
|
238
|
+
if matches = decs.match(/\s*(.[^:]*)\s*\:\s*(.[^;]*)\s*(;|\Z)/i)
|
239
|
+
property, value, end_of_declaration = matches.captures
|
240
|
+
output[property] = value
|
241
|
+
end
|
237
242
|
end
|
243
|
+
output
|
238
244
|
end
|
239
|
-
end
|
240
|
-
end
|
241
245
|
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
+
def determine_style_for(element)
|
247
|
+
if @css_parser
|
248
|
+
tag_style = @css_parser.find_by_selector(element.name)
|
249
|
+
id_style = @css_parser.find_by_selector("##{element.attributes["id"]}") if element.attributes["id"]
|
246
250
|
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
251
|
+
if classes = element.attributes["class"]
|
252
|
+
class_styles = classes.strip.split(/\s+/).collect do |class_name|
|
253
|
+
@css_parser.find_by_selector(".#{class_name}")
|
254
|
+
end
|
255
|
+
end
|
252
256
|
|
253
|
-
|
257
|
+
element_style = element.attributes['style']
|
254
258
|
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
else
|
259
|
-
style = element.attributes['style'] || ""
|
260
|
-
end
|
261
|
-
|
262
|
-
decs = parse_css_declarations(style)
|
263
|
-
element.attributes.each {|n,v| decs[n] = v unless decs[n]}
|
264
|
-
decs
|
265
|
-
end
|
266
|
-
|
267
|
-
def apply_styles(element, calls, state)
|
268
|
-
decs = determine_style_for(element)
|
269
|
-
draw_types = []
|
270
|
-
|
271
|
-
# Transform
|
272
|
-
if transform = decs['transform']
|
273
|
-
parse_css_method_calls(transform).each do |name, arguments|
|
274
|
-
case name
|
275
|
-
when 'translate'
|
276
|
-
x, y = arguments
|
277
|
-
x, y = x.split(/\s+/) if y.nil?
|
278
|
-
calls << [name, [distance(x), -distance(y)], []]
|
279
|
-
calls = calls.last.last
|
280
|
-
when 'rotate'
|
281
|
-
calls << [name, [-arguments.first.to_f, {:origin => [0, y('0')]}], []]
|
282
|
-
calls = calls.last.last
|
283
|
-
when 'scale'
|
284
|
-
calls << [name, [arguments.first.to_f], []]
|
285
|
-
calls = calls.last.last
|
259
|
+
style = [tag_style, class_styles, id_style, element_style].flatten.collect do |s|
|
260
|
+
s.nil? || s.strip == "" ? "" : "#{s}#{";" unless s.match(/;\s*\z/)}"
|
261
|
+
end.join
|
286
262
|
else
|
287
|
-
|
263
|
+
style = element.attributes['style'] || ""
|
288
264
|
end
|
265
|
+
|
266
|
+
decs = parse_css_declarations(style)
|
267
|
+
element.attributes.each {|n,v| decs[n] = v unless decs[n]}
|
268
|
+
decs
|
289
269
|
end
|
290
|
-
|
270
|
+
|
271
|
+
def apply_styles(element, calls, state)
|
272
|
+
decs = determine_style_for(element)
|
273
|
+
draw_types = []
|
274
|
+
|
275
|
+
# Transform
|
276
|
+
if transform = decs['transform']
|
277
|
+
parse_css_method_calls(transform).each do |name, arguments|
|
278
|
+
case name
|
279
|
+
when 'translate'
|
280
|
+
x, y = arguments
|
281
|
+
x, y = x.split(/\s+/) if y.nil?
|
282
|
+
calls << [name, [distance(x), -distance(y)], []]
|
283
|
+
calls = calls.last.last
|
284
|
+
when 'rotate'
|
285
|
+
calls << [name, [-arguments.first.to_f, {:origin => [0, y('0')]}], []]
|
286
|
+
calls = calls.last.last
|
287
|
+
when 'scale'
|
288
|
+
calls << [name, [arguments.first.to_f], []]
|
289
|
+
calls = calls.last.last
|
290
|
+
else
|
291
|
+
@warnings << "Unknown transformation '#{name}'; ignoring"
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
291
295
|
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
296
|
+
# Opacity:
|
297
|
+
# We can't do nested opacities quite like the SVG requires, but this is close enough.
|
298
|
+
fill_opacity = stroke_opacity = clamp(decs['opacity'].to_f, 0, 1) if decs['opacity']
|
299
|
+
fill_opacity = clamp(decs['fill-opacity'].to_f, 0, 1) if decs['fill-opacity']
|
300
|
+
stroke_opacity = clamp(decs['stroke-opacity'].to_f, 0, 1) if decs['stroke-opacity']
|
297
301
|
|
298
|
-
|
299
|
-
|
300
|
-
|
302
|
+
if fill_opacity || stroke_opacity
|
303
|
+
state[:fill_opacity] = (state[:fill_opacity] || 1) * (fill_opacity || 1)
|
304
|
+
state[:stroke_opacity] = (state[:stroke_opacity] || 1) * (stroke_opacity || 1)
|
301
305
|
|
302
|
-
|
303
|
-
|
304
|
-
|
306
|
+
calls << ['transparent', [state[:fill_opacity], state[:stroke_opacity]], []]
|
307
|
+
calls = calls.last.last
|
308
|
+
end
|
305
309
|
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
310
|
+
# Fill and stroke
|
311
|
+
if decs['fill'] && decs['fill'] != "none"
|
312
|
+
if color = color_to_hex(decs['fill'])
|
313
|
+
calls << ['fill_color', [color], []]
|
314
|
+
end
|
315
|
+
draw_types << 'fill'
|
316
|
+
end
|
313
317
|
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
318
|
+
if decs['stroke'] && decs['stroke'] != "none"
|
319
|
+
if color = color_to_hex(decs['stroke'])
|
320
|
+
calls << ['stroke_color', [color], []]
|
321
|
+
end
|
322
|
+
draw_types << 'stroke'
|
323
|
+
end
|
320
324
|
|
321
|
-
|
325
|
+
calls << ['line_width', [distance(decs['stroke-width'])], []] if decs['stroke-width']
|
322
326
|
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
327
|
+
draw_type = draw_types.join("_and_")
|
328
|
+
state[:draw_type] = draw_type if draw_type != ""
|
329
|
+
if state[:draw_type] && !%w(g svg).include?(element.name)
|
330
|
+
calls << [state[:draw_type], [], []]
|
331
|
+
calls = calls.last.last
|
332
|
+
end
|
329
333
|
|
330
|
-
|
331
|
-
|
334
|
+
[calls, decs]
|
335
|
+
end
|
332
336
|
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
337
|
+
def parse_css_method_calls(string)
|
338
|
+
string.scan(/\s*(\w+)\(([^)]+)\)\s*/).collect do |call|
|
339
|
+
name, argument_string = call
|
340
|
+
arguments = argument_string.split(",").collect(&:strip)
|
341
|
+
[name, arguments]
|
342
|
+
end
|
343
|
+
end
|
340
344
|
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
345
|
+
BUILT_IN_FONTS = ["Courier", "Helvetica", "Times-Roman", "Symbol", "ZapfDingbats"]
|
346
|
+
GENERIC_CSS_FONT_MAPPING = {
|
347
|
+
"serif" => "Times-Roman",
|
348
|
+
"sans-serif" => "Helvetica",
|
349
|
+
"cursive" => "Times-Roman",
|
350
|
+
"fantasy" => "Times-Roman",
|
351
|
+
"monospace" => "Courier"}
|
348
352
|
|
349
|
-
|
350
|
-
|
351
|
-
|
353
|
+
def installed_fonts
|
354
|
+
@installed_fonts ||= Prawn::Svg::Interface.font_path.uniq.collect {|path| Dir["#{path}/*"]}.flatten
|
355
|
+
end
|
352
356
|
|
353
|
-
|
354
|
-
|
355
|
-
|
357
|
+
def map_font_family_to_pdf_font(font_family)
|
358
|
+
font_family.split(",").detect do |font|
|
359
|
+
font = font.gsub(/['"]/, '').gsub(/\s{2,}/, ' ').strip.downcase
|
356
360
|
|
357
|
-
|
358
|
-
|
361
|
+
built_in_font = BUILT_IN_FONTS.detect {|f| f.downcase == font}
|
362
|
+
break built_in_font if built_in_font
|
359
363
|
|
360
|
-
|
361
|
-
|
364
|
+
generic_font = GENERIC_CSS_FONT_MAPPING[font]
|
365
|
+
break generic_font if generic_font
|
362
366
|
|
363
|
-
|
364
|
-
|
367
|
+
installed_font = installed_fonts.detect do |file|
|
368
|
+
(matches = File.basename(file).match(/(.+)\./)) && matches[1].downcase == font
|
369
|
+
end
|
370
|
+
break installed_font if installed_font
|
371
|
+
end
|
365
372
|
end
|
366
|
-
break installed_font if installed_font
|
367
|
-
end
|
368
|
-
end
|
369
373
|
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
374
|
+
# TODO : use http://www.w3.org/TR/SVG11/types.html#ColorKeywords
|
375
|
+
HTML_COLORS = {
|
376
|
+
'black' => "000000", 'green' => "008000", 'silver' => "c0c0c0", 'lime' => "00ff00",
|
377
|
+
'gray' => "808080", 'olive' => "808000", 'white' => "ffffff", 'yellow' => "ffff00",
|
378
|
+
'maroon' => "800000", 'navy' => "000080", 'red' => "ff0000", 'blue' => "0000ff",
|
379
|
+
'purple' => "800080", 'teal' => "008080", 'fuchsia' => "ff00ff", 'aqua' => "00ffff"
|
380
|
+
}.freeze
|
377
381
|
|
378
|
-
|
379
|
-
|
382
|
+
RGB_VALUE_REGEXP = "\s*(-?[0-9.]+%?)\s*"
|
383
|
+
RGB_REGEXP = /\Argb\(#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP}\)\z/i
|
380
384
|
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
385
|
+
def color_to_hex(color_string)
|
386
|
+
color_string.scan(/([^(\s]+(\([^)]*\))?)/).detect do |color, *_|
|
387
|
+
if m = color.match(/\A#([0-9a-f])([0-9a-f])([0-9a-f])\z/i)
|
388
|
+
break "#{m[1] * 2}#{m[2] * 2}#{m[3] * 2}"
|
389
|
+
elsif color.match(/\A#[0-9a-f]{6}\z/i)
|
390
|
+
break color[1..6]
|
391
|
+
elsif hex = HTML_COLORS[color.downcase]
|
392
|
+
break hex
|
393
|
+
elsif m = color.match(RGB_REGEXP)
|
394
|
+
break (1..3).collect do |n|
|
395
|
+
value = m[n].to_f
|
396
|
+
value *= 2.55 if m[n][-1..-1] == '%'
|
397
|
+
"%02x" % clamp(value.round, 0, 255)
|
398
|
+
end.join
|
399
|
+
end
|
400
|
+
end
|
401
|
+
end
|
398
402
|
|
399
|
-
|
400
|
-
|
401
|
-
|
403
|
+
def x(value)
|
404
|
+
(points(value, :x) - @x_offset) * scale
|
405
|
+
end
|
402
406
|
|
403
|
-
|
404
|
-
|
405
|
-
|
407
|
+
def y(value)
|
408
|
+
(@actual_height - (points(value, :y) - @y_offset)) * scale
|
409
|
+
end
|
406
410
|
|
407
|
-
|
408
|
-
|
409
|
-
|
411
|
+
def distance(value, axis = nil)
|
412
|
+
value && (points(value, axis) * scale)
|
413
|
+
end
|
410
414
|
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
415
|
+
def points(value, axis = nil)
|
416
|
+
if value.is_a?(String)
|
417
|
+
if match = value.match(/\d(cm|dm|ft|in|m|mm|yd)$/)
|
418
|
+
send("#{match[1]}2pt", value.to_f)
|
419
|
+
elsif value[-1..-1] == "%"
|
420
|
+
value.to_f * (axis == :y ? @actual_height : @actual_width) / 100.0
|
421
|
+
else
|
422
|
+
value.to_f
|
423
|
+
end
|
424
|
+
else
|
425
|
+
value.to_f
|
426
|
+
end
|
419
427
|
end
|
420
|
-
else
|
421
|
-
value.to_f
|
422
|
-
end
|
423
|
-
end
|
424
428
|
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
429
|
+
def calculate_dimensions
|
430
|
+
if @options[:width]
|
431
|
+
@width = @options[:width]
|
432
|
+
@scale = @options[:width] / @actual_width.to_f
|
433
|
+
elsif @options[:height]
|
434
|
+
@height = @options[:height]
|
435
|
+
@scale = @options[:height] / @actual_height.to_f
|
436
|
+
else
|
437
|
+
@scale = 1
|
438
|
+
end
|
435
439
|
|
436
|
-
|
437
|
-
|
438
|
-
|
440
|
+
@width ||= @actual_width * @scale
|
441
|
+
@height ||= @actual_height * @scale
|
442
|
+
end
|
439
443
|
|
440
|
-
|
441
|
-
|
442
|
-
|
444
|
+
def clamp(value, min_value, max_value)
|
445
|
+
[[value, min_value].max, max_value].min
|
446
|
+
end
|
443
447
|
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
+
def check_attrs_present(element, attrs)
|
449
|
+
missing_attrs = attrs - element.attributes.keys
|
450
|
+
if missing_attrs.any?
|
451
|
+
@warnings << "Must have attributes #{missing_attrs.join(", ")} on tag #{element.name}; skipping tag"
|
452
|
+
end
|
453
|
+
missing_attrs.empty?
|
454
|
+
end
|
448
455
|
end
|
449
|
-
missing_attrs.empty?
|
450
456
|
end
|
451
457
|
end
|