prawn-svg 0.23.1 → 0.24.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +3 -3
  3. data/README.md +15 -9
  4. data/lib/prawn-svg.rb +3 -0
  5. data/lib/prawn/svg/attributes.rb +1 -1
  6. data/lib/prawn/svg/attributes/clip_path.rb +6 -7
  7. data/lib/prawn/svg/attributes/opacity.rb +3 -3
  8. data/lib/prawn/svg/attributes/stroke.rb +6 -4
  9. data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +114 -0
  10. data/lib/prawn/svg/elements.rb +4 -1
  11. data/lib/prawn/svg/elements/base.rb +76 -69
  12. data/lib/prawn/svg/elements/container.rb +5 -6
  13. data/lib/prawn/svg/elements/gradient.rb +4 -4
  14. data/lib/prawn/svg/elements/image.rb +1 -1
  15. data/lib/prawn/svg/elements/line.rb +15 -7
  16. data/lib/prawn/svg/elements/marker.rb +72 -0
  17. data/lib/prawn/svg/elements/path.rb +23 -147
  18. data/lib/prawn/svg/elements/polygon.rb +14 -6
  19. data/lib/prawn/svg/elements/polyline.rb +12 -11
  20. data/lib/prawn/svg/elements/root.rb +3 -1
  21. data/lib/prawn/svg/elements/text.rb +38 -17
  22. data/lib/prawn/svg/font.rb +6 -6
  23. data/lib/prawn/svg/interface.rb +3 -0
  24. data/lib/prawn/svg/pathable.rb +130 -0
  25. data/lib/prawn/svg/properties.rb +122 -0
  26. data/lib/prawn/svg/state.rb +7 -29
  27. data/lib/prawn/svg/version.rb +1 -1
  28. data/spec/prawn/svg/elements/base_spec.rb +19 -32
  29. data/spec/prawn/svg/elements/line_spec.rb +37 -0
  30. data/spec/prawn/svg/elements/marker_spec.rb +90 -0
  31. data/spec/prawn/svg/elements/path_spec.rb +10 -10
  32. data/spec/prawn/svg/elements/polygon_spec.rb +49 -0
  33. data/spec/prawn/svg/elements/polyline_spec.rb +47 -0
  34. data/spec/prawn/svg/elements/style_spec.rb +1 -1
  35. data/spec/prawn/svg/elements/text_spec.rb +37 -5
  36. data/spec/prawn/svg/pathable_spec.rb +92 -0
  37. data/spec/prawn/svg/properties_spec.rb +186 -0
  38. data/spec/sample_svg/arrows.svg +73 -0
  39. data/spec/sample_svg/marker.svg +32 -0
  40. data/spec/sample_svg/polygon01.svg +25 -5
  41. metadata +23 -8
  42. data/lib/prawn/svg/attributes/color.rb +0 -5
  43. data/lib/prawn/svg/attributes/display.rb +0 -5
  44. data/lib/prawn/svg/attributes/font.rb +0 -38
  45. data/spec/prawn/svg/attributes/font_spec.rb +0 -52
@@ -1,22 +1,23 @@
1
1
  class Prawn::SVG::Elements::Polyline < Prawn::SVG::Elements::Base
2
+ include Prawn::SVG::Pathable
3
+
2
4
  def parse
3
5
  require_attributes('points')
4
6
  @points = parse_points(attributes['points'])
5
7
  end
6
8
 
7
9
  def apply
8
- raise SkipElementQuietly unless @points.length > 0
9
-
10
- add_call 'move_to', *@points[0]
11
- add_call_and_enter 'stroke'
12
- @points[1..-1].each do |x, y|
13
- add_call "line_to", x, y
14
- end
10
+ apply_commands
11
+ apply_markers
15
12
  end
16
13
 
17
- def bounding_box
18
- x1, x2 = @points.map(&:first).minmax
19
- y2, y1 = @points.map(&:last).minmax
20
- [x1, y1, x2, y2]
14
+ protected
15
+
16
+ def commands
17
+ @commands ||= [
18
+ Prawn::SVG::Pathable::Move.new(@points[0])
19
+ ] + @points[1..-1].map { |point|
20
+ Prawn::SVG::Pathable::Line.new(point)
21
+ }
21
22
  end
22
23
  end
@@ -7,7 +7,9 @@ class Prawn::SVG::Elements::Root < Prawn::SVG::Elements::Base
7
7
  add_call 'fill_color', '000000'
8
8
  add_call 'transformation_matrix', @document.sizing.x_scale, 0, 0, @document.sizing.y_scale, 0, 0
9
9
  add_call 'transformation_matrix', 1, 0, 0, 1, @document.sizing.x_offset, @document.sizing.y_offset
10
+ end
10
11
 
11
- process_child_elements
12
+ def container?
13
+ true
12
14
  end
13
15
  end
@@ -20,7 +20,10 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
20
20
  end
21
21
 
22
22
  def apply
23
- raise SkipElementQuietly if state.display == "none"
23
+ raise SkipElementQuietly if computed_properties.display == "none"
24
+
25
+ font = select_font
26
+ apply_font(font) if font
24
27
 
25
28
  add_call_and_enter "text_group" if name == 'text'
26
29
 
@@ -28,22 +31,18 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
28
31
  add_call_and_enter "translate", document.distance(attributes['dx'] || 0), -document.distance(attributes['dy'] || 0)
29
32
  end
30
33
 
31
- opts = {}
32
- if size = state.font_size
33
- opts[:size] = size
34
- end
35
- opts[:style] = state.font_subfamily
34
+ # text_anchor isn't a Prawn option; we have to do some math to support it
35
+ # and so we handle this in Prawn::SVG::Interface#rewrite_call_arguments
36
+ opts = {
37
+ size: computed_properties.numerical_font_size,
38
+ style: font && font.subfamily,
39
+ text_anchor: computed_properties.text_anchor
40
+ }
36
41
 
37
- # This is not a prawn option but we can't work out how to render it here -
38
- # it's handled by SVG#rewrite_call_arguments
39
- if (anchor = attributes['text-anchor'] || state.text_anchor) &&
40
- ['start', 'middle', 'end'].include?(anchor)
41
- opts[:text_anchor] = anchor
42
- end
42
+ spacing = computed_properties.letter_spacing
43
+ spacing = spacing == 'normal' ? 0 : document.points(spacing)
43
44
 
44
- if spacing = attributes['letter-spacing']
45
- add_call_and_enter 'character_spacing', document.points(spacing)
46
- end
45
+ add_call_and_enter 'character_spacing', spacing
47
46
 
48
47
  source.children.each do |child|
49
48
  if child.node_type == :text
@@ -53,7 +52,6 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
53
52
  opts[:at] = [@x_positions.first, @y_positions.first]
54
53
 
55
54
  if @x_positions.length > 1 || @y_positions.length > 1
56
- # TODO : isn't this just text.shift ?
57
55
  add_call 'draw_text', text[0..0], opts.dup
58
56
  text = text[1..-1]
59
57
 
@@ -73,7 +71,6 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
73
71
  new_state.text_x_positions = @x_positions
74
72
  new_state.text_y_positions = @y_positions
75
73
  new_state.text_relative = @relative
76
- new_state.text_anchor = opts[:text_anchor]
77
74
 
78
75
  Prawn::SVG::Elements::Text.new(document, child, calls, new_state).process
79
76
 
@@ -83,5 +80,29 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
83
80
  warnings << "Unknown tag '#{child.name}' inside text tag; ignoring"
84
81
  end
85
82
  end
83
+
84
+ # It's possible there was no text to render. In that case, add a 'noop' so
85
+ # character_spacing doesn't blow up when it finds it doesn't have a block to execute.
86
+ add_call 'noop' if calls.empty?
87
+ end
88
+
89
+ private
90
+
91
+ def select_font
92
+ font_families = [computed_properties.font_family, document.fallback_font_name]
93
+ font_style = :italic if computed_properties.font_style == 'italic'
94
+ font_weight = Prawn::SVG::Font.weight_for_css_font_weight(computed_properties.font_weight)
95
+
96
+ font_families.compact.each do |name|
97
+ font = document.font_registry.load(name, font_weight, font_style)
98
+ return font if font
99
+ end
100
+
101
+ warnings << "Font family '#{computed_properties.font_family}' style '#{computed_properties.font_style}' is not a known font, and the fallback font could not be found."
102
+ nil
103
+ end
104
+
105
+ def apply_font(font)
106
+ add_call 'font', font.name, style: font.subfamily
86
107
  end
87
108
  end
@@ -11,12 +11,12 @@ class Prawn::SVG::Font
11
11
 
12
12
  def self.weight_for_css_font_weight(weight)
13
13
  case weight
14
- when '100', '200', '300' then :light
15
- when '400', '500' then :normal
16
- when '600' then :semibold
17
- when '700', 'bold' then :bold
18
- when '800' then :extrabold
19
- when '900' then :black
14
+ when '100', '200', '300' then :light
15
+ when '400', '500', 'normal' then :normal
16
+ when '600' then :semibold
17
+ when '700', 'bold' then :bold
18
+ when '800' then :extrabold
19
+ when '900' then :black
20
20
  end
21
21
  end
22
22
 
@@ -169,6 +169,9 @@ module Prawn
169
169
  # never want closepath to be automatically run as it stuffs up many drawing operations, such as dashes
170
170
  # and line caps, and makes paths close that we didn't ask to be closed when fill is specified.
171
171
  prawn.add_content 'B'
172
+
173
+ when 'noop'
174
+ yield
172
175
  end
173
176
  end
174
177
 
@@ -0,0 +1,130 @@
1
+ module Prawn::SVG::Pathable
2
+ Move = Struct.new(:destination)
3
+ Close = Struct.new(:destination)
4
+ Line = Struct.new(:destination)
5
+ Curve = Struct.new(:destination, :point1, :point2)
6
+
7
+ def bounding_box
8
+ points = commands.map { |command| translate(command.destination) }
9
+ x1, x2 = points.map(&:first).minmax
10
+ y2, y1 = points.map(&:last).minmax
11
+
12
+ [x1, y1, x2, y2]
13
+ end
14
+
15
+ protected
16
+
17
+ def apply_commands
18
+ commands.each do |command|
19
+ case command
20
+ when Move
21
+ add_call 'move_to', translate(command.destination)
22
+ when Close
23
+ add_call 'close_path'
24
+ when Line
25
+ add_call 'line_to', translate(command.destination)
26
+ when Curve
27
+ add_call 'curve_to', translate(command.destination), bounds: [translate(command.point1), translate(command.point2)]
28
+ else
29
+ raise NotImplementedError, "Unknown path command type"
30
+ end
31
+ end
32
+ end
33
+
34
+ def apply_markers
35
+ if marker = extract_element_from_url_id_reference(properties.marker_start, "marker")
36
+ marker.apply_marker(self, point: commands.first.destination, angle: angles.first)
37
+ end
38
+
39
+ if marker = extract_element_from_url_id_reference(properties.marker_mid, "marker")
40
+ (1..commands.length-2).each do |index|
41
+ marker.apply_marker(self, point: commands[index].destination, angle: angles[index])
42
+ end
43
+ end
44
+
45
+ if marker = extract_element_from_url_id_reference(properties.marker_end, "marker")
46
+ marker.apply_marker(self, point: commands.last.destination, angle: angles.last)
47
+ end
48
+ end
49
+
50
+ def angles
51
+ return @angles if @angles
52
+
53
+ last_point = nil
54
+
55
+ destination_angles = commands.map do |command|
56
+ angles = case command
57
+ when Move
58
+ [nil, nil]
59
+ when Close, Line
60
+ angle = Math.atan2(command.destination[1] - last_point[1], command.destination[0] - last_point[0]) * 180.0 / Math::PI
61
+ [angle, angle]
62
+ when Curve
63
+ start = Math.atan2(command.point1[1] - last_point[1], command.point1[0] - last_point[0]) * 180.0 / Math::PI
64
+ stop = Math.atan2(command.destination[1] - command.point2[1], command.destination[0] - command.point2[0]) * 180.0 / Math::PI
65
+ [start, stop]
66
+ else
67
+ raise NotImplementedError, "Unknown path command type"
68
+ end
69
+
70
+ last_point = command.destination
71
+ angles
72
+ end
73
+
74
+ angles = destination_angles.each_cons(2).map do |first_angles, second_angles|
75
+ if first_angles.first.nil?
76
+ second_angles.first || 0
77
+ elsif second_angles.first.nil?
78
+ first_angles.last
79
+ else
80
+ first = first_angles.last
81
+ second = second_angles.last
82
+ bisect = (first + second) / 2.0
83
+
84
+ if (first - second).abs > 180
85
+ bisect >= 0 ? bisect - 180 : bisect + 180
86
+ else
87
+ bisect
88
+ end
89
+ end
90
+ end
91
+
92
+ if commands.last.is_a?(Close)
93
+ first = destination_angles.last.last || 0
94
+ second = angles.first
95
+ bisect = (first + second) / 2.0
96
+
97
+ angles << if (first - second).abs > 180
98
+ bisect >= 0 ? bisect - 180 : bisect + 180
99
+ else
100
+ bisect
101
+ end
102
+ else
103
+ angles << destination_angles.last.last
104
+ end
105
+
106
+ @angles = angles
107
+ end
108
+
109
+ def parse_points(points_string)
110
+ values = points_string.
111
+ to_s.
112
+ strip.
113
+ gsub(/(\d)-(\d)/, '\1 -\2').
114
+ split(Prawn::SVG::Elements::COMMA_WSP_REGEXP).
115
+ map(&:to_f)
116
+
117
+ if values.length % 2 == 1
118
+ document.warnings << "points attribute has an odd number of points; ignoring the last one"
119
+ values.pop
120
+ end
121
+
122
+ raise Prawn::SVG::Elements::Base::SkipElementQuietly if values.length == 0
123
+
124
+ values.each_slice(2).to_a
125
+ end
126
+
127
+ def translate(point)
128
+ [point[0].to_f, document.sizing.output_height - point[1].to_f]
129
+ end
130
+ end
@@ -0,0 +1,122 @@
1
+ class Prawn::SVG::Properties
2
+ Config = Struct.new(:default, :inheritable?, :keywords, :keyword_restricted?, :attr, :ivar)
3
+
4
+ EM = 16
5
+ FONT_SIZES = {
6
+ 'xx-small' => EM / 4,
7
+ 'x-small' => EM / 4 * 2,
8
+ 'small' => EM / 4 * 3,
9
+ 'medium' => EM / 4 * 4,
10
+ 'large' => EM / 4 * 5,
11
+ 'x-large' => EM / 4 * 6,
12
+ 'xx-large' => EM / 4 * 7
13
+ }
14
+
15
+ PROPERTIES = {
16
+ "clip-path" => Config.new("none", false, %w(inherit none)),
17
+ "color" => Config.new('', true),
18
+ "display" => Config.new("inline", false, %w(inherit inline none), true),
19
+ "fill" => Config.new("black", true, %w(inherit none currentColor)),
20
+ "fill-opacity" => Config.new("1", true),
21
+ "font-family" => Config.new("sans-serif", true),
22
+ "font-size" => Config.new("medium", true, %w(inherit xx-small x-small small medium large x-large xx-large larger smaller)),
23
+ "font-style" => Config.new("normal", true, %w(inherit normal italic oblique), true),
24
+ "font-variant" => Config.new("normal", true, %w(inherit normal small-caps), true),
25
+ "font-weight" => Config.new("normal", true, %w(inherit normal bold 100 200 300 400 500 600 700 800 900), true), # bolder/lighter not supported
26
+ "letter-spacing" => Config.new("normal", true, %w(inherit normal)),
27
+ "marker-end" => Config.new("none", true, %w(inherit none)),
28
+ "marker-mid" => Config.new("none", true, %w(inherit none)),
29
+ "marker-start" => Config.new("none", true, %w(inherit none)),
30
+ "opacity" => Config.new("1", false),
31
+ "overflow" => Config.new('visible', false, %w(inherit visible hidden scroll auto), true),
32
+ "stop-color" => Config.new("black", false, %w(inherit none currentColor)),
33
+ "stroke" => Config.new("none", true, %w(inherit none currentColor)),
34
+ "stroke-dasharray" => Config.new("none", true, %w(inherit none)),
35
+ "stroke-linecap" => Config.new("butt", true, %w(inherit butt round square), true),
36
+ "stroke-opacity" => Config.new("1", true),
37
+ "stroke-width" => Config.new("1", true),
38
+ "text-anchor" => Config.new("start", true, %w(inherit start middle end), true),
39
+ }.freeze
40
+
41
+ PROPERTIES.each do |name, value|
42
+ value.attr = name.gsub("-", "_")
43
+ value.ivar = "@#{value.attr}"
44
+ end
45
+
46
+ PROPERTY_CONFIGS = PROPERTIES.values
47
+ NAMES = PROPERTIES.keys
48
+ ATTR_NAMES = PROPERTIES.keys.map { |name| name.gsub('-', '_') }
49
+
50
+ attr_accessor *ATTR_NAMES
51
+
52
+ def load_default_stylesheet
53
+ PROPERTY_CONFIGS.each do |config|
54
+ instance_variable_set(config.ivar, config.default)
55
+ end
56
+
57
+ self
58
+ end
59
+
60
+ def set(name, value)
61
+ if config = PROPERTIES[name.to_s.downcase]
62
+ value = value.strip
63
+ keyword = value.downcase
64
+ keywords = config.keywords || ['inherit']
65
+
66
+ if keywords.include?(keyword)
67
+ value = keyword
68
+ elsif config.keyword_restricted?
69
+ value = config.default
70
+ end
71
+
72
+ instance_variable_set(config.ivar, value)
73
+ end
74
+ end
75
+
76
+ def to_h
77
+ PROPERTIES.each.with_object({}) do |(name, config), result|
78
+ result[name] = instance_variable_get(config.ivar)
79
+ end
80
+ end
81
+
82
+ def load_hash(hash)
83
+ hash.each { |name, value| set(name, value) if value }
84
+ end
85
+
86
+ def compute_properties(other)
87
+ PROPERTY_CONFIGS.each do |config|
88
+ value = other.send(config.attr)
89
+
90
+ if value && value != 'inherit'
91
+ value = compute_font_size_property(value).to_s if config.attr == "font_size"
92
+ instance_variable_set(config.ivar, value)
93
+
94
+ elsif value.nil? && !config.inheritable?
95
+ instance_variable_set(config.ivar, config.default)
96
+ end
97
+ end
98
+ end
99
+
100
+ def numerical_font_size
101
+ # px = pt for PDFs
102
+ FONT_SIZES[font_size] || font_size.to_f
103
+ end
104
+
105
+ private
106
+
107
+ def compute_font_size_property(value)
108
+ if value[-1] == "%"
109
+ numerical_font_size * (value.to_f / 100.0)
110
+ elsif value == 'larger'
111
+ numerical_font_size + 4
112
+ elsif value == 'smaller'
113
+ numerical_font_size - 4
114
+ elsif value.match(/(\d|\.)em\z/i)
115
+ numerical_font_size * value.to_f
116
+ elsif value.match(/(\d|\.)rem\z/i)
117
+ value.to_f * EM
118
+ else
119
+ FONT_SIZES[value] || value.to_f
120
+ end
121
+ end
122
+ end
@@ -1,39 +1,17 @@
1
1
  class Prawn::SVG::State
2
2
  attr_accessor :disable_drawing,
3
- :color, :display,
4
- :font_size, :font_weight, :font_style, :font_family, :font_subfamily,
5
- :text_anchor, :text_relative, :text_x_positions, :text_y_positions, :preserve_space,
6
- :fill_opacity, :stroke_opacity,
7
- :fill, :stroke
3
+ :text_relative, :text_x_positions, :text_y_positions, :preserve_space,
4
+ :fill_opacity, :stroke_opacity, :stroke_width,
5
+ :computed_properties
8
6
 
9
7
  def initialize
10
- @fill = true
11
- @stroke = false
8
+ @stroke_width = 1
12
9
  @fill_opacity = 1
13
10
  @stroke_opacity = 1
11
+ @computed_properties = Prawn::SVG::Properties.new.load_default_stylesheet
14
12
  end
15
13
 
16
- def enable_draw_type(type)
17
- case type
18
- when 'fill' then @fill = true
19
- when 'stroke' then @stroke = true
20
- else raise
21
- end
22
- end
23
-
24
- def disable_draw_type(type)
25
- case type
26
- when 'fill' then @fill = false
27
- when 'stroke' then @stroke = false
28
- else raise
29
- end
30
- end
31
-
32
- def draw_type(type)
33
- case type
34
- when 'fill' then @fill
35
- when 'stroke' then @stroke
36
- else raise
37
- end
14
+ def initialize_dup(other)
15
+ @computed_properties = @computed_properties.dup
38
16
  end
39
17
  end