prawn-svg 0.9.1.8 → 0.9.1.9

Sign up to get free protection for your applications and to get access to all the features.
data/README CHANGED
@@ -28,15 +28,18 @@ prawn-svg is in its infancy and does not support the full SVG specifications. I
28
28
  supports moveto, closepath, lineto, horiz lineto, vert lineto, curveto, smooth curveto, quad curveto, smooth quad curveto
29
29
  does not support elliptical arc
30
30
 
31
- - text tag
32
- attributes: size, text-anchor
33
- partially supported attributes: font-family
31
+ - text and tspan tags
32
+ attributes: size, text-anchor, font-family, font-weight, dx, dy
33
+
34
+ - svg, g and symbol tags
35
+
36
+ - use tag
34
37
 
35
38
  - style tag, if css_parser gem is installed on the system [1]
36
39
 
37
40
  - attributes/styles: fill, stroke, stroke-width, opacity, fill-opacity, stroke-opacity, transform
38
41
 
39
- - transform methods: translate, rotate, scale
42
+ - transform methods: translate, rotate, scale, matrix
40
43
 
41
44
  - colors: html standard names, #xxx, #xxxxxx, rgb(1, 2, 3), rgb(1%, 2%, 3%)
42
45
 
@@ -49,6 +52,6 @@ Mac OS X and Debian Linux users. You can add to the font path:
49
52
 
50
53
  Prawn::Svg.font_path << "/my/font/directory"
51
54
 
52
- prawn-svg does NOT support named elements, external references, the tspan tag, gradients/patterns or markers.
55
+ prawn-svg does NOT support external references, gradients/patterns or markers.
53
56
 
54
57
  [1] If the css_parser gem is installed, it will handle CSS style definitions, but only simple tag, class or id definitions.
data/lib/prawn-svg.rb CHANGED
@@ -1,7 +1,11 @@
1
1
  require 'prawn'
2
2
  require 'prawn/svg/extension'
3
3
  require 'prawn/svg/interface'
4
+ require 'prawn/svg/font'
5
+ require 'prawn/svg/document'
6
+ require 'prawn/svg/element'
4
7
  require 'prawn/svg/parser'
5
8
  require 'prawn/svg/parser/path'
9
+ require 'prawn/svg/parser/text'
6
10
 
7
11
  Prawn::Document.extensions << Prawn::Svg::Extension
@@ -0,0 +1,81 @@
1
+ class Prawn::Svg::Document
2
+ include Prawn::Measurements
3
+
4
+ begin
5
+ require 'css_parser'
6
+ CSS_PARSER_LOADED = true
7
+ rescue LoadError
8
+ CSS_PARSER_LOADED = false
9
+ end
10
+
11
+ DEFAULT_WIDTH = 640
12
+ DEFAULT_HEIGHT = 480
13
+
14
+ # An +Array+ of warnings that occurred while parsing the SVG data.
15
+ attr_reader :warnings
16
+
17
+ # The scaling factor, as determined by the :width or :height options.
18
+ attr_accessor :scale
19
+
20
+ attr_reader :root,
21
+ :actual_width, :actual_height, :width, :height, :x_offset, :y_offset,
22
+ :css_parser
23
+
24
+ def initialize(data, bounds, options)
25
+ @css_parser = CssParser::Parser.new if CSS_PARSER_LOADED
26
+
27
+ @root = REXML::Document.new(data).root
28
+ @warnings = []
29
+ @options = options
30
+ @actual_width, @actual_height = bounds # set this first so % width/heights can be used
31
+
32
+ if vb = @root.attributes['viewBox']
33
+ x1, y1, x2, y2 = vb.strip.split(/\s+/)
34
+ @x_offset, @y_offset = [x1.to_f, y1.to_f]
35
+ @actual_width, @actual_height = [x2.to_f - x1.to_f, y2.to_f - y1.to_f]
36
+ else
37
+ @x_offset, @y_offset = [0, 0]
38
+ @actual_width = points(@root.attributes['width'] || DEFAULT_WIDTH, :x)
39
+ @actual_height = points(@root.attributes['height'] || DEFAULT_HEIGHT, :y)
40
+ end
41
+
42
+ if @options[:width]
43
+ @width = @options[:width]
44
+ @scale = @options[:width] / @actual_width.to_f
45
+ elsif @options[:height]
46
+ @height = @options[:height]
47
+ @scale = @options[:height] / @actual_height.to_f
48
+ else
49
+ @scale = 1
50
+ end
51
+
52
+ @width ||= @actual_width * @scale
53
+ @height ||= @actual_height * @scale
54
+ end
55
+
56
+ def x(value)
57
+ (points(value, :x) - @x_offset) * scale
58
+ end
59
+
60
+ def y(value)
61
+ (@actual_height - (points(value, :y) - @y_offset)) * scale
62
+ end
63
+
64
+ def distance(value, axis = nil)
65
+ value && (points(value, axis) * scale)
66
+ end
67
+
68
+ def points(value, axis = nil)
69
+ if value.is_a?(String)
70
+ if match = value.match(/\d(cm|dm|ft|in|m|mm|yd)$/)
71
+ send("#{match[1]}2pt", value.to_f)
72
+ elsif value[-1..-1] == "%"
73
+ value.to_f * (axis == :y ? @actual_height : @actual_width) / 100.0
74
+ else
75
+ value.to_f
76
+ end
77
+ else
78
+ value.to_f
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,220 @@
1
+ class Prawn::Svg::Element
2
+ attr_reader :document, :element, :parent_calls, :base_calls, :state, :attributes
3
+ attr_accessor :calls
4
+
5
+ def initialize(document, element, parent_calls, state)
6
+ @document = document
7
+ @element = element
8
+ @parent_calls = parent_calls
9
+ @state = state
10
+ @base_calls = @calls = []
11
+
12
+ combine_attributes_and_style_declarations
13
+ apply_styles
14
+
15
+ if id = @attributes["id"]
16
+ state[:ids][id] = @base_calls
17
+ end
18
+ end
19
+
20
+ def name
21
+ @name ||= element.name
22
+ end
23
+
24
+ def each_child_element
25
+ element.elements.each do |e|
26
+ yield self.class.new(@document, e, @calls, @state.dup)
27
+ end
28
+ end
29
+
30
+ def warnings
31
+ @document.warnings
32
+ end
33
+
34
+ def add_call(name, *arguments)
35
+ @calls << [name.to_s, arguments, []]
36
+ end
37
+
38
+ def add_call_and_enter(name, *arguments)
39
+ @calls << [name.to_s, arguments, []]
40
+ @calls = @calls.last.last
41
+ end
42
+
43
+ def append_calls_to_parent
44
+ @parent_calls.concat(@base_calls)
45
+ end
46
+
47
+
48
+ protected
49
+ def apply_styles
50
+ # Transform
51
+ if transform = @attributes['transform']
52
+ parse_css_method_calls(transform).each do |name, arguments|
53
+ case name
54
+ when 'translate'
55
+ x, y = arguments
56
+ x, y = x.split(/\s+/) if y.nil?
57
+ add_call_and_enter name, @document.distance(x), -@document.distance(y)
58
+
59
+ when 'rotate'
60
+ add_call_and_enter name, -arguments.first.to_f, :origin => [0, @document.y('0')]
61
+
62
+ when 'scale'
63
+ args = arguments.first.split(/\s+/)
64
+ x_scale = args[0].to_f
65
+ y_scale = (args[1] || x_scale).to_f
66
+ add_call_and_enter "transformation_matrix", x_scale, 0, 0, y_scale, 0, 0
67
+
68
+ when 'matrix'
69
+ args = arguments.first.split(/\s+/)
70
+ if args.length != 6
71
+ @document.warnings << "transform 'matrix' must have six arguments"
72
+ else
73
+ a, b, c, d, e, f = args.collect {|a| a.to_f}
74
+ add_call_and_enter "transformation_matrix", a, b, c, d, @document.distance(e), -@document.distance(f)
75
+ end
76
+ else
77
+ @document.warnings << "Unknown transformation '#{name}'; ignoring"
78
+ end
79
+ end
80
+ end
81
+
82
+ # Opacity:
83
+ # We can't do nested opacities quite like the SVG requires, but this is close enough.
84
+ fill_opacity = stroke_opacity = clamp(@attributes['opacity'].to_f, 0, 1) if @attributes['opacity']
85
+ fill_opacity = clamp(@attributes['fill-opacity'].to_f, 0, 1) if @attributes['fill-opacity']
86
+ stroke_opacity = clamp(@attributes['stroke-opacity'].to_f, 0, 1) if @attributes['stroke-opacity']
87
+
88
+ if fill_opacity || stroke_opacity
89
+ state[:fill_opacity] = (state[:fill_opacity] || 1) * (fill_opacity || 1)
90
+ state[:stroke_opacity] = (state[:stroke_opacity] || 1) * (stroke_opacity || 1)
91
+
92
+ add_call_and_enter 'transparent', state[:fill_opacity], state[:stroke_opacity]
93
+ end
94
+
95
+ # Fill and stroke
96
+ draw_types = []
97
+ [:fill, :stroke].each do |type|
98
+ dec = @attributes[type.to_s]
99
+ if dec == "none"
100
+ state[type] = false
101
+ elsif dec
102
+ state[type] = true
103
+ if color = color_to_hex(dec)
104
+ add_call "#{type}_color", color
105
+ end
106
+ end
107
+
108
+ draw_types << type.to_s if state[type]
109
+ end
110
+
111
+ # Stroke width
112
+ add_call('line_width', @document.distance(@attributes['stroke-width'])) if @attributes['stroke-width']
113
+
114
+ # Fonts
115
+ if size = @attributes['font-size']
116
+ @state[:font_size] = size.to_f * @document.scale
117
+ end
118
+ if weight = @attributes['font-weight']
119
+ font_updated = true
120
+ @state[:font_style] = weight == 'bold' ? :bold : nil
121
+ end
122
+ if (family = @attributes['font-family']) && family.strip != ""
123
+ font_updated = true
124
+ @state[:font_family] = family
125
+ end
126
+
127
+ if @state[:font_family] && font_updated
128
+ if pdf_font = Prawn::Svg::Font.map_font_family_to_pdf_font(@state[:font_family], @state[:font_style])
129
+ add_call_and_enter 'font', pdf_font
130
+ else
131
+ @document.warnings << "Font family '#{@state[:font_family]}' style '#{@state[:font_style] || 'normal'}' is not a known font."
132
+ end
133
+ end
134
+
135
+ # Call fill, stroke, or both
136
+ draw_type = draw_types.join("_and_")
137
+ if draw_type != "" && !Prawn::Svg::Parser::CONTAINER_TAGS.include?(element.name)
138
+ add_call_and_enter draw_type
139
+ end
140
+ end
141
+
142
+ def parse_css_method_calls(string)
143
+ string.scan(/\s*(\w+)\(([^)]+)\)\s*/).collect do |call|
144
+ name, argument_string = call
145
+ arguments = argument_string.split(",").collect {|s| s.strip}
146
+ [name, arguments]
147
+ end
148
+ end
149
+
150
+ # TODO : use http://www.w3.org/TR/SVG11/types.html#ColorKeywords
151
+ HTML_COLORS = {
152
+ 'black' => "000000", 'green' => "008000", 'silver' => "c0c0c0", 'lime' => "00ff00",
153
+ 'gray' => "808080", 'olive' => "808000", 'white' => "ffffff", 'yellow' => "ffff00",
154
+ 'maroon' => "800000", 'navy' => "000080", 'red' => "ff0000", 'blue' => "0000ff",
155
+ 'purple' => "800080", 'teal' => "008080", 'fuchsia' => "ff00ff", 'aqua' => "00ffff"
156
+ }.freeze
157
+
158
+ RGB_VALUE_REGEXP = "\s*(-?[0-9.]+%?)\s*"
159
+ RGB_REGEXP = /\Argb\(#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP}\)\z/i
160
+
161
+ def color_to_hex(color_string)
162
+ color_string.scan(/([^(\s]+(\([^)]*\))?)/).detect do |color, *_|
163
+ if m = color.match(/\A#([0-9a-f])([0-9a-f])([0-9a-f])\z/i)
164
+ break "#{m[1] * 2}#{m[2] * 2}#{m[3] * 2}"
165
+ elsif color.match(/\A#[0-9a-f]{6}\z/i)
166
+ break color[1..6]
167
+ elsif hex = HTML_COLORS[color.downcase]
168
+ break hex
169
+ elsif m = color.match(RGB_REGEXP)
170
+ break (1..3).collect do |n|
171
+ value = m[n].to_f
172
+ value *= 2.55 if m[n][-1..-1] == '%'
173
+ "%02x" % clamp(value.round, 0, 255)
174
+ end.join
175
+ end
176
+ end
177
+ end
178
+
179
+ def clamp(value, min_value, max_value)
180
+ [[value, min_value].max, max_value].min
181
+ end
182
+
183
+ def combine_attributes_and_style_declarations
184
+ if @document && @document.css_parser
185
+ tag_style = @document.css_parser.find_by_selector(element.name)
186
+ id_style = @document.css_parser.find_by_selector("##{element.attributes["id"]}") if element.attributes["id"]
187
+
188
+ if classes = element.attributes["class"]
189
+ class_styles = classes.strip.split(/\s+/).collect do |class_name|
190
+ @document.css_parser.find_by_selector(".#{class_name}")
191
+ end
192
+ end
193
+
194
+ element_style = element.attributes['style']
195
+
196
+ style = [tag_style, class_styles, id_style, element_style].flatten.collect do |s|
197
+ s.nil? || s.strip == "" ? "" : "#{s}#{";" unless s.match(/;\s*\z/)}"
198
+ end.join
199
+ else
200
+ style = element.attributes['style'] || ""
201
+ end
202
+
203
+ @attributes = parse_css_declarations(style)
204
+ element.attributes.each {|n,v| @attributes[n] = v unless @attributes[n]}
205
+ end
206
+
207
+ def parse_css_declarations(declarations)
208
+ # copied from css_parser
209
+ declarations.gsub!(/(^[\s]*)|([\s]*$)/, '')
210
+
211
+ output = {}
212
+ declarations.split(/[\;$]+/m).each do |decs|
213
+ if matches = decs.match(/\s*(.[^:]*)\s*\:\s*(.[^;]*)\s*(;|\Z)/i)
214
+ property, value, end_of_declaration = matches.captures
215
+ output[property] = value
216
+ end
217
+ end
218
+ output
219
+ end
220
+ end
@@ -17,7 +17,7 @@ module Prawn
17
17
  def svg(data, options={})
18
18
  svg = Prawn::Svg::Interface.new(data, self, options)
19
19
  svg.draw
20
- {:warnings => svg.parser_warnings, :width => svg.parser.width, :height => svg.parser.height}
20
+ {:warnings => svg.document.warnings, :width => svg.document.width, :height => svg.document.height}
21
21
  end
22
22
  end
23
23
  end
@@ -0,0 +1,100 @@
1
+ require 'iconv'
2
+
3
+ class Prawn::Svg::Font
4
+ BUILT_IN_FONTS = ["Courier", "Helvetica", "Times-Roman", "Symbol", "ZapfDingbats"]
5
+
6
+ GENERIC_CSS_FONT_MAPPING = {
7
+ "serif" => "Times-Roman",
8
+ "sans-serif" => "Helvetica",
9
+ "cursive" => "Times-Roman",
10
+ "fantasy" => "Times-Roman",
11
+ "monospace" => "Courier"}
12
+
13
+ def self.map_font_family_to_pdf_font(font_family, font_style = nil)
14
+ font_family.split(",").detect do |font|
15
+ font = font.gsub(/['"]/, '').gsub(/\s{2,}/, ' ').strip.downcase
16
+
17
+ built_in_font = BUILT_IN_FONTS.detect {|f| f.downcase == font}
18
+ break built_in_font if built_in_font
19
+
20
+ generic_font = GENERIC_CSS_FONT_MAPPING[font]
21
+ break generic_font if generic_font
22
+
23
+ break font.downcase if font_installed?(font, font_style)
24
+ end
25
+ end
26
+
27
+ def self.font_path(font_family, font_style = nil)
28
+ font_style = :normal if font_style.nil?
29
+ if installed_styles = installed_fonts[font_family.downcase]
30
+ installed_styles[font_style]
31
+ end
32
+ end
33
+
34
+ def self.font_installed?(font_family, font_style = nil)
35
+ !font_path(font_family, font_style).nil?
36
+ end
37
+
38
+ def self.installed_fonts
39
+ return @installed_fonts if @installed_fonts
40
+
41
+ fonts = {}
42
+ Prawn::Svg::Interface.font_path.uniq.collect {|path| Dir["#{path}/*"]}.flatten.each do |filename|
43
+ information = font_information(filename) rescue nil
44
+ if information && font_name = information[1]
45
+ font_style = case information[2]
46
+ when 'Bold' then :bold
47
+ when 'Italic' then :italic
48
+ when 'Bold Italic' then :bold_italic
49
+ else :normal
50
+ end
51
+
52
+ (fonts[font_name.downcase] ||= {})[font_style] = filename
53
+ end
54
+ end
55
+
56
+ @installed_fonts = fonts
57
+ end
58
+
59
+ def self.font_information(filename)
60
+ File.open(filename, "r") do |f|
61
+ x = f.read(12)
62
+ table_count = x[4] * 256 + x[5]
63
+ tables = f.read(table_count * 16)
64
+
65
+ offset, length = table_count.times do |index|
66
+ start = index * 16
67
+ if tables[start..start+3] == 'name'
68
+ break tables[start+8..start+15].unpack("NN")
69
+ end
70
+ end
71
+
72
+ return unless length
73
+ f.seek(offset)
74
+ data = f.read(length)
75
+
76
+ format, name_count, string_offset = data[0..5].unpack("nnn")
77
+
78
+ names = {}
79
+ name_count.times do |index|
80
+ start = 6 + index * 12
81
+ platform_id, platform_specific_id, language_id, name_id, length, offset = data[start..start+11].unpack("nnnnnn")
82
+ next unless language_id == 0 # English
83
+ next unless name_id == 1 || name_id == 2
84
+
85
+ offset += string_offset
86
+ field = data[offset..offset+length-1]
87
+ names[name_id] = if platform_id == 0
88
+ begin
89
+ Iconv.iconv('UTF-8', 'UTF-16', field)
90
+ rescue
91
+ field
92
+ end
93
+ else
94
+ field
95
+ end
96
+ end
97
+ names
98
+ end
99
+ end
100
+ end
@@ -1,5 +1,5 @@
1
1
  #
2
- # Prawn::Svg::Interface makes a Prawn::Svg::Parser instance, uses that object to parse the supplied
2
+ # Prawn::Svg::Interface makes a Prawn::Svg::Document instance, uses that object to parse the supplied
3
3
  # SVG into Prawn-compatible method calls, and then calls the Prawn methods.
4
4
  #
5
5
  module Prawn
@@ -12,12 +12,8 @@ module Prawn
12
12
 
13
13
  class << self; attr_accessor :font_path; end
14
14
 
15
- attr_reader :data, :prawn, :parser, :options
15
+ attr_reader :data, :prawn, :document, :options
16
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
17
  #
22
18
  # Creates a Prawn::Svg object.
23
19
  #
@@ -35,17 +31,18 @@ module Prawn
35
31
 
36
32
  @options[:at] or raise "options[:at] must be specified"
37
33
 
38
- @parser = Parser.new(data, [prawn.bounds.width, prawn.bounds.height], options)
39
- @parser_warnings = @parser.warnings
34
+ prawn.font_families.update(Prawn::Svg::Font.installed_fonts)
35
+
36
+ @document = Document.new(data, [prawn.bounds.width, prawn.bounds.height], options)
40
37
  end
41
38
 
42
39
  #
43
40
  # Draws the SVG to the Prawn::Document object.
44
41
  #
45
42
  def draw
46
- prawn.bounding_box(@options[:at], :width => @parser.width, :height => @parser.height) do
43
+ prawn.bounding_box(@options[:at], :width => @document.width, :height => @document.height) do
47
44
  prawn.save_graphics_state do
48
- proc_creator(prawn, @parser.parse).call
45
+ proc_creator(prawn, Parser.new(@document).parse).call
49
46
  end
50
47
  end
51
48
  end
@@ -71,15 +68,40 @@ module Prawn
71
68
  end
72
69
 
73
70
  def rewrite_call_arguments(prawn, call, arguments)
71
+ if call == 'relative_draw_text'
72
+ call.replace "draw_text"
73
+ arguments.last[:at][0] = @relative_text_position if @relative_text_position
74
+ end
75
+
74
76
  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)
77
+ when 'text_group'
78
+ @relative_text_position = nil
79
+ false
80
+
81
+ when 'draw_text'
82
+ text, options = arguments
83
+
84
+ width = prawn.width_of(text, options.merge(:kerning => true))
85
+
86
+ if (anchor = options.delete(:text_anchor)) && %w(middle end).include?(anchor)
78
87
  width /= 2 if anchor == 'middle'
79
- arguments.last[:at][0] -= width
88
+ options[:at][0] -= width
80
89
  end
81
-
82
- arguments.last[:at][1] += prawn.height_of(*arguments) / 3 * 2
90
+
91
+ space_width = prawn.width_of("n", options)
92
+ @relative_text_position = options[:at][0] + width + space_width
93
+
94
+ when 'transformation_matrix'
95
+ arguments[4] += prawn.bounds.absolute_left * (1 - arguments[0])
96
+ arguments[5] += prawn.bounds.absolute_top * (1 - arguments[3])
97
+
98
+ when 'save'
99
+ prawn.save_graphics_state
100
+ false
101
+
102
+ when 'restore'
103
+ prawn.restore_graphics_state
104
+ false
83
105
  end
84
106
  end
85
107
  end
@@ -11,447 +11,205 @@ 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
- 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
23
-
24
- include Prawn::Measurements
25
-
26
- attr_reader :width, :height
27
-
28
- # An +Array+ of warnings that occurred while parsing the SVG data.
29
- attr_reader :warnings
14
+ class Prawn::Svg::Parser
15
+ CONTAINER_TAGS = %w(g svg symbol defs)
16
+
17
+ #
18
+ # Construct a Parser object.
19
+ #
20
+ # The +data+ argument is SVG data.
21
+ #
22
+ # +bounds+ is a tuple [width, height] that specifies the bounds of the drawing space in points.
23
+ #
24
+ # +options+ can optionally contain
25
+ # the key :width or :height. If both are specified, only :width will be used.
26
+ #
27
+ def initialize(document)
28
+ @document = document
29
+ end
30
30
 
31
- # The scaling factor, as determined by the :width or :height options.
32
- attr_accessor :scale
33
-
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
31
+ #
32
+ # Parse the SVG data and return a call tree. The returned +Array+ is in the format:
33
+ #
34
+ # [
35
+ # ['prawn_method_name', ['argument1', 'argument2'], []],
36
+ # ['method_that_takes_a_block', ['argument1', 'argument2'], [
37
+ # ['method_called_inside_block', ['argument'], []]
38
+ # ]
39
+ # ]
40
+ #
41
+ def parse
42
+ @document.warnings.clear
50
43
 
51
- if data
52
- parse_document
53
- calculate_dimensions
54
- end
55
- end
44
+ calls = [['fill_color', '000000', []]]
45
+ root_element = Prawn::Svg::Element.new(@document, @document.root, calls, :ids => {}, :fill => true)
46
+
47
+ parse_element(root_element)
48
+ calls
49
+ end
56
50
 
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
73
51
 
52
+ private
53
+ REQUIRED_ATTRIBUTES = {
54
+ "line" => %w(x1 y1 x2 y2),
55
+ "polyline" => %w(points),
56
+ "polygon" => %w(points),
57
+ "circle" => %w(r),
58
+ "ellipse" => %w(rx ry),
59
+ "rect" => %w(width height),
60
+ "path" => %w(d)
61
+ }
74
62
 
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
63
+ def parse_element(element)
64
+ attrs = element.attributes
79
65
 
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
66
+ if required_attributes = REQUIRED_ATTRIBUTES[element.name]
67
+ return unless check_attrs_present(element, required_attributes)
68
+ end
69
+
70
+ case element.name
71
+ when *CONTAINER_TAGS
72
+ element.each_child_element do |child|
73
+ element.add_call "save"
74
+ parse_element(child)
75
+ element.add_call "restore"
89
76
  end
90
-
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
- }
100
-
101
- def parse_element(element, calls, state)
102
- attrs = element.attributes
103
- calls, style_attrs = apply_styles(element, calls, state)
104
-
105
- if required_attributes = REQUIRED_ATTRIBUTES[element.name]
106
- return unless check_attrs_present(element, required_attributes)
107
- end
108
-
109
- case element.name
110
- when 'title', 'desc'
111
- # ignore
112
-
113
- when 'g', 'svg'
114
- element.elements.each do |child|
115
- parse_element(child, calls, state.dup)
116
- end
117
-
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
123
-
124
- when 'style'
125
- load_css_styles(element)
126
-
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
139
77
 
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
144
-
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
78
+ do_not_append_calls = %w(symbol defs).include?(element.name)
79
+
80
+ when 'style'
81
+ load_css_styles(element)
82
+
83
+ when 'text'
84
+ @svg_text ||= Text.new
85
+ @svg_text.parse(element)
86
+
87
+ when 'line'
88
+ element.add_call 'line', x(attrs['x1']), y(attrs['y1']), x(attrs['x2']), y(attrs['y2'])
89
+
90
+ when 'polyline'
91
+ points = attrs['points'].split(/\s+/)
92
+ return unless base_point = points.shift
93
+ x, y = base_point.split(",")
94
+ element.add_call 'move_to', x(x), y(y)
95
+ element.add_call_and_enter 'stroke'
96
+ points.each do |point|
97
+ x, y = point.split(",")
98
+ element.add_call "line_to", x(x), y(y)
99
+ end
100
+
101
+ when 'polygon'
102
+ points = attrs['points'].split(/\s+/).collect do |point|
103
+ x, y = point.split(",")
104
+ [x(x), y(y)]
105
+ end
106
+ element.add_call "polygon", points
107
+
108
+ when 'circle'
109
+ element.add_call "circle_at",
110
+ [x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], :radius => distance(attrs['r'])
111
+
112
+ when 'ellipse'
113
+ element.add_call "ellipse_at",
114
+ [x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], distance(attrs['rx']), distance(attrs['ry'])
115
+
116
+ when 'rect'
117
+ radius = distance(attrs['rx'] || attrs['ry'])
118
+ args = [[x(attrs['x'] || '0'), y(attrs['y'] || '0')], distance(attrs['width']), distance(attrs['height'])]
119
+ if radius
120
+ # n.b. does not support both rx and ry being specified with different values
121
+ element.add_call "rounded_rectangle", *(args + [radius])
122
+ else
123
+ element.add_call "rectangle", *args
124
+ end
125
+
126
+ when 'path'
127
+ parse_path(element)
150
128
 
151
- calls << ['text_box', [element.text, opts], []]
129
+ when 'use'
130
+ parse_use(element)
152
131
 
153
- when 'line'
154
- calls << ['line', [x(attrs['x1']), y(attrs['y1']), x(attrs['x2']), y(attrs['y2'])], []]
155
-
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
167
-
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, []]
174
-
175
- when 'circle'
176
- calls << ["circle_at",
177
- [[x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], {:radius => distance(attrs['r'])}],
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
- []]
184
-
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
132
+ when 'title', 'desc', 'metadata'
133
+ # ignore
134
+ do_not_append_calls = true
194
135
 
195
- when 'path'
196
- @svg_path ||= Path.new
197
-
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
204
-
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"
217
- end
218
- end
136
+ when 'font-face'
137
+ # not supported
138
+ do_not_append_calls = true
219
139
 
220
- def load_css_styles(element)
221
- if @css_parser
222
- data = if element.cdatas.any?
223
- element.cdatas.collect {|d| d.to_s}.join
224
- else
225
- element.text
226
- end
140
+ else
141
+ @document.warnings << "Unknown tag '#{element.name}'; ignoring"
142
+ end
227
143
 
228
- @css_parser.add_block!(data)
229
- end
230
- end
231
-
232
- def parse_css_declarations(declarations)
233
- # copied from css_parser
234
- declarations.gsub!(/(^[\s]*)|([\s]*$)/, '')
235
-
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
242
- end
243
- output
244
- end
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"]
250
-
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
256
-
257
- element_style = element.attributes['style']
258
-
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
262
- else
263
- style = element.attributes['style'] || ""
264
- end
144
+ element.append_calls_to_parent unless do_not_append_calls
145
+ end
265
146
 
266
- decs = parse_css_declarations(style)
267
- element.attributes.each {|n,v| decs[n] = v unless decs[n]}
268
- decs
269
- end
270
147
 
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
295
-
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']
301
-
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)
148
+ def parse_path(element)
149
+ @svg_path ||= Path.new
305
150
 
306
- calls << ['transparent', [state[:fill_opacity], state[:stroke_opacity]], []]
307
- calls = calls.last.last
308
- end
309
-
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
317
-
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
324
-
325
- calls << ['line_width', [distance(decs['stroke-width'])], []] if decs['stroke-width']
326
-
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
333
-
334
- [calls, decs]
335
- end
336
-
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 {|s| s.strip}
341
- [name, arguments]
342
- end
343
- end
344
-
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"}
352
-
353
- def installed_fonts
354
- @installed_fonts ||= Prawn::Svg::Interface.font_path.uniq.collect {|path| Dir["#{path}/*"]}.flatten
355
- end
356
-
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
360
-
361
- built_in_font = BUILT_IN_FONTS.detect {|f| f.downcase == font}
362
- break built_in_font if built_in_font
363
-
364
- generic_font = GENERIC_CSS_FONT_MAPPING[font]
365
- break generic_font if generic_font
366
-
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
372
- end
151
+ begin
152
+ commands = @svg_path.parse(element.attributes['d'])
153
+ rescue Prawn::Svg::Parser::Path::InvalidError => e
154
+ commands = []
155
+ @document.warnings << e.message
156
+ end
373
157
 
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
381
-
382
- RGB_VALUE_REGEXP = "\s*(-?[0-9.]+%?)\s*"
383
- RGB_REGEXP = /\Argb\(#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP}\)\z/i
384
-
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
402
-
403
- def x(value)
404
- (points(value, :x) - @x_offset) * scale
405
- end
406
-
407
- def y(value)
408
- (@actual_height - (points(value, :y) - @y_offset)) * scale
409
- end
410
-
411
- def distance(value, axis = nil)
412
- value && (points(value, axis) * scale)
413
- end
414
-
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
158
+ commands.collect do |command, args|
159
+ point_to = [x(args[0]), y(args[1])]
160
+ if command == 'curve_to'
161
+ opts = {:bounds => [[x(args[2]), y(args[3])], [x(args[4]), y(args[5])]]}
427
162
  end
163
+ element.add_call command, point_to, opts
164
+ end
165
+ end
428
166
 
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
167
+ def parse_use(element)
168
+ if href = element.attributes['xlink:href']
169
+ if href[0..0] == '#'
170
+ id = href[1..-1]
171
+ if id_calls = element.state[:ids][id]
172
+ x = element.attributes['x']
173
+ y = element.attributes['y']
174
+ if x || y
175
+ element.add_call_and_enter "translate", distance(x || 0), -distance(y || 0)
176
+ end
177
+
178
+ element.calls.concat(id_calls)
436
179
  else
437
- @scale = 1
180
+ @document.warnings << "no tag with ID '#{id}' was found, referenced by use tag"
438
181
  end
439
-
440
- @width ||= @actual_width * @scale
441
- @height ||= @actual_height * @scale
182
+ else
183
+ @document.warnings << "use tag has an href that is not a reference to an id; this is not supported"
442
184
  end
185
+ else
186
+ @document.warnings << "no xlink:href specified on use tag"
187
+ end
188
+ end
443
189
 
444
- def clamp(value, min_value, max_value)
445
- [[value, min_value].max, max_value].min
446
- end
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?
190
+ ####################################################################################################################
191
+
192
+ def load_css_styles(element)
193
+ if @document.css_parser
194
+ data = if element.element.cdatas.any?
195
+ element.element.cdatas.collect {|d| d.to_s}.join
196
+ else
197
+ element.element.text
454
198
  end
199
+
200
+ @document.css_parser.add_block!(data)
201
+ end
202
+ end
203
+
204
+ def check_attrs_present(element, attrs)
205
+ missing_attrs = attrs - element.attributes.keys
206
+ if missing_attrs.any?
207
+ @document.warnings << "Must have attributes #{missing_attrs.join(", ")} on tag #{element.name}; skipping tag"
455
208
  end
209
+ missing_attrs.empty?
210
+ end
211
+
212
+ %w(x y distance).each do |method|
213
+ define_method(method) {|*a| @document.send(method, *a)}
456
214
  end
457
215
  end
@@ -0,0 +1,67 @@
1
+ class Prawn::Svg::Parser::Text
2
+ def parse(element)
3
+ element.add_call_and_enter "text_group"
4
+ internal_parse(element, [element.document.x(0)], [element.document.y(0)], false)
5
+ end
6
+
7
+ protected
8
+ def internal_parse(element, x_positions, y_positions, relative)
9
+ attrs = element.attributes
10
+
11
+ if attrs['x'] || attrs['y']
12
+ relative = false
13
+ x_positions = attrs['x'].split(/[\s,]+/).collect {|n| element.document.x(n)} if attrs['x']
14
+ y_positions = attrs['y'].split(/[\s,]+/).collect {|n| element.document.y(n)} if attrs['y']
15
+ end
16
+
17
+ if attrs['dx'] || attrs['dy']
18
+ element.add_call_and_enter "translate", element.document.distance(attrs['dx'] || 0), -element.document.distance(attrs['dy'] || 0)
19
+ end
20
+
21
+ opts = {}
22
+ if size = element.state[:font_size]
23
+ opts[:size] = size
24
+ end
25
+ if style = element.state[:font_style]
26
+ opts[:style] = style
27
+ end
28
+
29
+ # This is not a prawn option but we can't work out how to render it here -
30
+ # it's handled by Svg#rewrite_call_arguments
31
+ if anchor = attrs['text-anchor']
32
+ opts[:text_anchor] = anchor
33
+ end
34
+
35
+ element.element.children.each do |child|
36
+ if child.node_type == :text
37
+ text = child.to_s.strip.gsub(/\s+/, " ")
38
+
39
+ while text != ""
40
+ opts[:at] = [x_positions.first, y_positions.first]
41
+
42
+ if x_positions.length > 1 || y_positions.length > 1
43
+ element.add_call 'draw_text', text[0..0], opts.dup
44
+ text = text[1..-1]
45
+
46
+ x_positions.shift if x_positions.length > 1
47
+ y_positions.shift if y_positions.length > 1
48
+ else
49
+ element.add_call relative ? 'relative_draw_text' : 'draw_text', text, opts.dup
50
+ relative = true
51
+ break
52
+ end
53
+ end
54
+
55
+ elsif child.name == "tspan"
56
+ element.add_call 'save'
57
+ child_element = Prawn::Svg::Element.new(element.document, child, element.calls, element.state.dup)
58
+ internal_parse(child_element, x_positions, y_positions, relative)
59
+ child_element.append_calls_to_parent
60
+ element.add_call 'restore'
61
+
62
+ else
63
+ element.warnings << "Unknown tag '#{child.name}' inside text tag; ignoring"
64
+ end
65
+ end
66
+ end
67
+ end
metadata CHANGED
@@ -6,8 +6,8 @@ version: !ruby/object:Gem::Version
6
6
  - 0
7
7
  - 9
8
8
  - 1
9
- - 8
10
- version: 0.9.1.8
9
+ - 9
10
+ version: 0.9.1.9
11
11
  platform: ruby
12
12
  authors:
13
13
  - Roger Nesbitt
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2010-05-09 00:00:00 +12:00
18
+ date: 2010-06-06 00:00:00 +01:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -57,9 +57,13 @@ extra_rdoc_files: []
57
57
  files:
58
58
  - README
59
59
  - LICENSE
60
+ - lib/prawn/svg/document.rb
61
+ - lib/prawn/svg/element.rb
60
62
  - lib/prawn/svg/extension.rb
63
+ - lib/prawn/svg/font.rb
61
64
  - lib/prawn/svg/interface.rb
62
65
  - lib/prawn/svg/parser/path.rb
66
+ - lib/prawn/svg/parser/text.rb
63
67
  - lib/prawn/svg/parser.rb
64
68
  - lib/prawn-svg.rb
65
69
  has_rdoc: true