prawn-svg 0.9.1.8 → 0.9.1.9

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