lib/prawn/svg/parser.rb in prawn-svg-0.9.1.8 vs lib/prawn/svg/parser.rb in prawn-svg-0.9.1.9
- old
+ new
@@ -9,449 +9,207 @@
#
# This class is not passed the prawn object, so knows nothing about
# prawn specifically - this might be useful if you want to take this code and use it to convert
# SVG to another format.
#
-module Prawn
- module Svg
- class Parser
- begin
- require 'css_parser'
- CSS_PARSER_LOADED = true
- rescue LoadError
- CSS_PARSER_LOADED = false
- end
+class Prawn::Svg::Parser
+ CONTAINER_TAGS = %w(g svg symbol defs)
- include Prawn::Measurements
-
- attr_reader :width, :height
-
- # An +Array+ of warnings that occurred while parsing the SVG data.
- attr_reader :warnings
+ #
+ # Construct a Parser object.
+ #
+ # The +data+ argument is SVG data.
+ #
+ # +bounds+ is a tuple [width, height] that specifies the bounds of the drawing space in points.
+ #
+ # +options+ can optionally contain
+ # the key :width or :height. If both are specified, only :width will be used.
+ #
+ def initialize(document)
+ @document = document
+ end
- # The scaling factor, as determined by the :width or :height options.
- attr_accessor :scale
-
- #
- # Construct a Parser object.
- #
- # The +data+ argument is SVG data.
- #
- # +bounds+ is a tuple [width, height] that specifies the bounds of the drawing space in points.
- #
- # +options+ can optionally contain
- # the key :width or :height. If both are specified, only :width will be used.
- #
- def initialize(data, bounds, options)
- @data = data
- @bounds = bounds
- @options = options
- @warnings = []
- @css_parser = CssParser::Parser.new if CSS_PARSER_LOADED
+ #
+ # Parse the SVG data and return a call tree. The returned +Array+ is in the format:
+ #
+ # [
+ # ['prawn_method_name', ['argument1', 'argument2'], []],
+ # ['method_that_takes_a_block', ['argument1', 'argument2'], [
+ # ['method_called_inside_block', ['argument'], []]
+ # ]
+ # ]
+ #
+ def parse
+ @document.warnings.clear
- if data
- parse_document
- calculate_dimensions
- end
- end
+ calls = [['fill_color', '000000', []]]
+ root_element = Prawn::Svg::Element.new(@document, @document.root, calls, :ids => {}, :fill => true)
+
+ parse_element(root_element)
+ calls
+ end
- #
- # Parse the SVG data and return a call tree. The returned +Array+ is in the format:
- #
- # [
- # ['prawn_method_name', ['argument1', 'argument2'], []],
- # ['method_that_takes_a_block', ['argument1', 'argument2'], [
- # ['method_called_inside_block', ['argument'], []]
- # ]
- # ]
- #
- def parse
- @warnings = []
- calls = []
- parse_element(@root, calls, {})
- calls
- end
+ private
+ REQUIRED_ATTRIBUTES = {
+ "line" => %w(x1 y1 x2 y2),
+ "polyline" => %w(points),
+ "polygon" => %w(points),
+ "circle" => %w(r),
+ "ellipse" => %w(rx ry),
+ "rect" => %w(width height),
+ "path" => %w(d)
+ }
- private
- def parse_document
- @root = REXML::Document.new(@data).root
- @actual_width, @actual_height = @bounds # set this first so % width/heights can be used
+ def parse_element(element)
+ attrs = element.attributes
- if vb = @root.attributes['viewBox']
- x1, y1, x2, y2 = vb.strip.split(/\s+/)
- @x_offset, @y_offset = [x1.to_f, y1.to_f]
- @actual_width, @actual_height = [x2.to_f - x1.to_f, y2.to_f - y1.to_f]
- else
- @x_offset, @y_offset = [0, 0]
- @actual_width = points(@root.attributes['width'], :x)
- @actual_height = points(@root.attributes['height'], :y)
- end
+ if required_attributes = REQUIRED_ATTRIBUTES[element.name]
+ return unless check_attrs_present(element, required_attributes)
+ end
+
+ case element.name
+ when *CONTAINER_TAGS
+ element.each_child_element do |child|
+ element.add_call "save"
+ parse_element(child)
+ element.add_call "restore"
end
-
- REQUIRED_ATTRIBUTES = {
- "line" => %w(x1 y1 x2 y2),
- "polyline" => %w(points),
- "polygon" => %w(points),
- "circle" => %w(r),
- "ellipse" => %w(rx ry),
- "rect" => %w(x y width height),
- "path" => %w(d)
- }
-
- def parse_element(element, calls, state)
- attrs = element.attributes
- calls, style_attrs = apply_styles(element, calls, state)
-
- if required_attributes = REQUIRED_ATTRIBUTES[element.name]
- return unless check_attrs_present(element, required_attributes)
- end
-
- case element.name
- when 'title', 'desc'
- # ignore
- when 'g', 'svg'
- element.elements.each do |child|
- parse_element(child, calls, state.dup)
- end
-
- when 'defs'
- # Pass calls as a blank array so that nothing under this tag can be added to our call tree.
- element.elements.each do |child|
- parse_element(child, [], state.dup.merge(:display => false))
- end
-
- when 'style'
- load_css_styles(element)
+ do_not_append_calls = %w(symbol defs).include?(element.name)
+
+ when 'style'
+ load_css_styles(element)
- when 'text'
- # Very primitive support for font-family; it won't work in most cases because
- # PDF only has a few built-in fonts, and they're not the same as the names
- # used typically with the web fonts.
- if font_family = style_attrs["font-family"]
- if font_family != "" && pdf_font = map_font_family_to_pdf_font(font_family)
- calls << ['font', [pdf_font], []]
- calls = calls.last.last
- else
- @warnings << "#{font_family} is not a known font."
- end
- end
-
- opts = {:at => [x(attrs['x']), y(attrs['y'])]}
- if size = style_attrs['font-size']
- opts[:size] = size.to_f * @scale
- end
-
- # This is not a prawn option but we can't work out how to render it here -
- # it's handled by Svg#rewrite_call_arguments
- if anchor = style_attrs['text-anchor']
- opts[:text_anchor] = anchor
- end
-
- calls << ['text_box', [element.text, opts], []]
+ when 'text'
+ @svg_text ||= Text.new
+ @svg_text.parse(element)
- when 'line'
- calls << ['line', [x(attrs['x1']), y(attrs['y1']), x(attrs['x2']), y(attrs['y2'])], []]
+ when 'line'
+ element.add_call 'line', x(attrs['x1']), y(attrs['y1']), x(attrs['x2']), y(attrs['y2'])
- when 'polyline'
- points = attrs['points'].split(/\s+/)
- return unless base_point = points.shift
- x, y = base_point.split(",")
- calls << ['move_to', [x(x), y(y)], []]
- calls << ['stroke', [], []]
- calls = calls.last.last
- points.each do |point|
- x, y = point.split(",")
- calls << ["line_to", [x(x), y(y)], []]
- end
-
- when 'polygon'
- points = attrs['points'].split(/\s+/).collect do |point|
- x, y = point.split(",")
- [x(x), y(y)]
- end
- calls << ["polygon", points, []]
-
- when 'circle'
- calls << ["circle_at",
- [[x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], {:radius => distance(attrs['r'])}],
- []]
-
- when 'ellipse'
- calls << ["ellipse_at",
- [[x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], distance(attrs['rx']), distance(attrs['ry'])],
- []]
-
- when 'rect'
- radius = distance(attrs['rx'] || attrs['ry'])
- args = [[x(attrs['x']), y(attrs['y'])], distance(attrs['width']), distance(attrs['height'])]
- if radius
- # n.b. does not support both rx and ry being specified with different values
- calls << ["rounded_rectangle", args + [radius], []]
- else
- calls << ["rectangle", args, []]
- end
-
- when 'path'
- @svg_path ||= Path.new
+ when 'polyline'
+ points = attrs['points'].split(/\s+/)
+ return unless base_point = points.shift
+ x, y = base_point.split(",")
+ element.add_call 'move_to', x(x), y(y)
+ element.add_call_and_enter 'stroke'
+ points.each do |point|
+ x, y = point.split(",")
+ element.add_call "line_to", x(x), y(y)
+ end
- begin
- commands = @svg_path.parse(attrs['d'])
- rescue Prawn::Svg::Parser::Path::InvalidError => e
- commands = []
- @warnings << e.message
- end
-
- commands.each do |command, args|
- point_to = [x(args[0]), y(args[1])]
- if command == 'curve_to'
- bounds = [[x(args[2]), y(args[3])], [x(args[4]), y(args[5])]]
- calls << [command, [point_to, {:bounds => bounds}], []]
- else
- calls << [command, point_to, []]
- end
- end
-
- else
- @warnings << "Unknown tag '#{element.name}'; ignoring"
- end
+ when 'polygon'
+ points = attrs['points'].split(/\s+/).collect do |point|
+ x, y = point.split(",")
+ [x(x), y(y)]
end
+ element.add_call "polygon", points
- def load_css_styles(element)
- if @css_parser
- data = if element.cdatas.any?
- element.cdatas.collect {|d| d.to_s}.join
- else
- element.text
- end
-
- @css_parser.add_block!(data)
- end
- end
+ when 'circle'
+ element.add_call "circle_at",
+ [x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], :radius => distance(attrs['r'])
- def parse_css_declarations(declarations)
- # copied from css_parser
- declarations.gsub!(/(^[\s]*)|([\s]*$)/, '')
-
- output = {}
- declarations.split(/[\;$]+/m).each do |decs|
- if matches = decs.match(/\s*(.[^:]*)\s*\:\s*(.[^;]*)\s*(;|\Z)/i)
- property, value, end_of_declaration = matches.captures
- output[property] = value
- end
- end
- output
+ when 'ellipse'
+ element.add_call "ellipse_at",
+ [x(attrs['cx'] || "0"), y(attrs['cy'] || "0")], distance(attrs['rx']), distance(attrs['ry'])
+
+ when 'rect'
+ radius = distance(attrs['rx'] || attrs['ry'])
+ args = [[x(attrs['x'] || '0'), y(attrs['y'] || '0')], distance(attrs['width']), distance(attrs['height'])]
+ if radius
+ # n.b. does not support both rx and ry being specified with different values
+ element.add_call "rounded_rectangle", *(args + [radius])
+ else
+ element.add_call "rectangle", *args
end
- def determine_style_for(element)
- if @css_parser
- tag_style = @css_parser.find_by_selector(element.name)
- id_style = @css_parser.find_by_selector("##{element.attributes["id"]}") if element.attributes["id"]
+ when 'path'
+ parse_path(element)
- if classes = element.attributes["class"]
- class_styles = classes.strip.split(/\s+/).collect do |class_name|
- @css_parser.find_by_selector(".#{class_name}")
- end
- end
-
- element_style = element.attributes['style']
+ when 'use'
+ parse_use(element)
- style = [tag_style, class_styles, id_style, element_style].flatten.collect do |s|
- s.nil? || s.strip == "" ? "" : "#{s}#{";" unless s.match(/;\s*\z/)}"
- end.join
- else
- style = element.attributes['style'] || ""
- end
-
- decs = parse_css_declarations(style)
- element.attributes.each {|n,v| decs[n] = v unless decs[n]}
- decs
- end
+ when 'title', 'desc', 'metadata'
+ # ignore
+ do_not_append_calls = true
+
+ when 'font-face'
+ # not supported
+ do_not_append_calls = true
- def apply_styles(element, calls, state)
- decs = determine_style_for(element)
- draw_types = []
+ else
+ @document.warnings << "Unknown tag '#{element.name}'; ignoring"
+ end
- # Transform
- if transform = decs['transform']
- parse_css_method_calls(transform).each do |name, arguments|
- case name
- when 'translate'
- x, y = arguments
- x, y = x.split(/\s+/) if y.nil?
- calls << [name, [distance(x), -distance(y)], []]
- calls = calls.last.last
- when 'rotate'
- calls << [name, [-arguments.first.to_f, {:origin => [0, y('0')]}], []]
- calls = calls.last.last
- when 'scale'
- calls << [name, [arguments.first.to_f], []]
- calls = calls.last.last
- else
- @warnings << "Unknown transformation '#{name}'; ignoring"
- end
- end
- end
-
- # Opacity:
- # We can't do nested opacities quite like the SVG requires, but this is close enough.
- fill_opacity = stroke_opacity = clamp(decs['opacity'].to_f, 0, 1) if decs['opacity']
- fill_opacity = clamp(decs['fill-opacity'].to_f, 0, 1) if decs['fill-opacity']
- stroke_opacity = clamp(decs['stroke-opacity'].to_f, 0, 1) if decs['stroke-opacity']
-
- if fill_opacity || stroke_opacity
- state[:fill_opacity] = (state[:fill_opacity] || 1) * (fill_opacity || 1)
- state[:stroke_opacity] = (state[:stroke_opacity] || 1) * (stroke_opacity || 1)
+ element.append_calls_to_parent unless do_not_append_calls
+ end
- calls << ['transparent', [state[:fill_opacity], state[:stroke_opacity]], []]
- calls = calls.last.last
- end
-
- # Fill and stroke
- if decs['fill'] && decs['fill'] != "none"
- if color = color_to_hex(decs['fill'])
- calls << ['fill_color', [color], []]
- end
- draw_types << 'fill'
- end
-
- if decs['stroke'] && decs['stroke'] != "none"
- if color = color_to_hex(decs['stroke'])
- calls << ['stroke_color', [color], []]
- end
- draw_types << 'stroke'
- end
-
- calls << ['line_width', [distance(decs['stroke-width'])], []] if decs['stroke-width']
-
- draw_type = draw_types.join("_and_")
- state[:draw_type] = draw_type if draw_type != ""
- if state[:draw_type] && !%w(g svg).include?(element.name)
- calls << [state[:draw_type], [], []]
- calls = calls.last.last
- end
-
- [calls, decs]
- end
- def parse_css_method_calls(string)
- string.scan(/\s*(\w+)\(([^)]+)\)\s*/).collect do |call|
- name, argument_string = call
- arguments = argument_string.split(",").collect {|s| s.strip}
- [name, arguments]
- end
- end
-
- BUILT_IN_FONTS = ["Courier", "Helvetica", "Times-Roman", "Symbol", "ZapfDingbats"]
- GENERIC_CSS_FONT_MAPPING = {
- "serif" => "Times-Roman",
- "sans-serif" => "Helvetica",
- "cursive" => "Times-Roman",
- "fantasy" => "Times-Roman",
- "monospace" => "Courier"}
-
- def installed_fonts
- @installed_fonts ||= Prawn::Svg::Interface.font_path.uniq.collect {|path| Dir["#{path}/*"]}.flatten
- end
-
- def map_font_family_to_pdf_font(font_family)
- font_family.split(",").detect do |font|
- font = font.gsub(/['"]/, '').gsub(/\s{2,}/, ' ').strip.downcase
-
- built_in_font = BUILT_IN_FONTS.detect {|f| f.downcase == font}
- break built_in_font if built_in_font
-
- generic_font = GENERIC_CSS_FONT_MAPPING[font]
- break generic_font if generic_font
-
- installed_font = installed_fonts.detect do |file|
- (matches = File.basename(file).match(/(.+)\./)) && matches[1].downcase == font
- end
- break installed_font if installed_font
- end
- end
+ def parse_path(element)
+ @svg_path ||= Path.new
- # TODO : use http://www.w3.org/TR/SVG11/types.html#ColorKeywords
- HTML_COLORS = {
- 'black' => "000000", 'green' => "008000", 'silver' => "c0c0c0", 'lime' => "00ff00",
- 'gray' => "808080", 'olive' => "808000", 'white' => "ffffff", 'yellow' => "ffff00",
- 'maroon' => "800000", 'navy' => "000080", 'red' => "ff0000", 'blue' => "0000ff",
- 'purple' => "800080", 'teal' => "008080", 'fuchsia' => "ff00ff", 'aqua' => "00ffff"
- }.freeze
-
- RGB_VALUE_REGEXP = "\s*(-?[0-9.]+%?)\s*"
- RGB_REGEXP = /\Argb\(#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP},#{RGB_VALUE_REGEXP}\)\z/i
-
- def color_to_hex(color_string)
- color_string.scan(/([^(\s]+(\([^)]*\))?)/).detect do |color, *_|
- if m = color.match(/\A#([0-9a-f])([0-9a-f])([0-9a-f])\z/i)
- break "#{m[1] * 2}#{m[2] * 2}#{m[3] * 2}"
- elsif color.match(/\A#[0-9a-f]{6}\z/i)
- break color[1..6]
- elsif hex = HTML_COLORS[color.downcase]
- break hex
- elsif m = color.match(RGB_REGEXP)
- break (1..3).collect do |n|
- value = m[n].to_f
- value *= 2.55 if m[n][-1..-1] == '%'
- "%02x" % clamp(value.round, 0, 255)
- end.join
- end
- end
+ begin
+ commands = @svg_path.parse(element.attributes['d'])
+ rescue Prawn::Svg::Parser::Path::InvalidError => e
+ commands = []
+ @document.warnings << e.message
+ end
+
+ commands.collect do |command, args|
+ point_to = [x(args[0]), y(args[1])]
+ if command == 'curve_to'
+ opts = {:bounds => [[x(args[2]), y(args[3])], [x(args[4]), y(args[5])]]}
end
+ element.add_call command, point_to, opts
+ end
+ end
- def x(value)
- (points(value, :x) - @x_offset) * scale
- end
-
- def y(value)
- (@actual_height - (points(value, :y) - @y_offset)) * scale
- end
-
- def distance(value, axis = nil)
- value && (points(value, axis) * scale)
- end
-
- def points(value, axis = nil)
- if value.is_a?(String)
- if match = value.match(/\d(cm|dm|ft|in|m|mm|yd)$/)
- send("#{match[1]}2pt", value.to_f)
- elsif value[-1..-1] == "%"
- value.to_f * (axis == :y ? @actual_height : @actual_width) / 100.0
- else
- value.to_f
+ def parse_use(element)
+ if href = element.attributes['xlink:href']
+ if href[0..0] == '#'
+ id = href[1..-1]
+ if id_calls = element.state[:ids][id]
+ x = element.attributes['x']
+ y = element.attributes['y']
+ if x || y
+ element.add_call_and_enter "translate", distance(x || 0), -distance(y || 0)
end
+
+ element.calls.concat(id_calls)
else
- value.to_f
+ @document.warnings << "no tag with ID '#{id}' was found, referenced by use tag"
end
+ else
+ @document.warnings << "use tag has an href that is not a reference to an id; this is not supported"
end
+ else
+ @document.warnings << "no xlink:href specified on use tag"
+ end
+ end
- def calculate_dimensions
- if @options[:width]
- @width = @options[:width]
- @scale = @options[:width] / @actual_width.to_f
- elsif @options[:height]
- @height = @options[:height]
- @scale = @options[:height] / @actual_height.to_f
- else
- @scale = 1
- end
-
- @width ||= @actual_width * @scale
- @height ||= @actual_height * @scale
+ ####################################################################################################################
+
+ def load_css_styles(element)
+ if @document.css_parser
+ data = if element.element.cdatas.any?
+ element.element.cdatas.collect {|d| d.to_s}.join
+ else
+ element.element.text
end
-
- def clamp(value, min_value, max_value)
- [[value, min_value].max, max_value].min
- end
-
- def check_attrs_present(element, attrs)
- missing_attrs = attrs - element.attributes.keys
- if missing_attrs.any?
- @warnings << "Must have attributes #{missing_attrs.join(", ")} on tag #{element.name}; skipping tag"
- end
- missing_attrs.empty?
- end
+
+ @document.css_parser.add_block!(data)
+ end
+ end
+
+ def check_attrs_present(element, attrs)
+ missing_attrs = attrs - element.attributes.keys
+ if missing_attrs.any?
+ @document.warnings << "Must have attributes #{missing_attrs.join(", ")} on tag #{element.name}; skipping tag"
end
+ missing_attrs.empty?
+ end
+
+ %w(x y distance).each do |method|
+ define_method(method) {|*a| @document.send(method, *a)}
end
end