prawn-svg 0.23.1 → 0.24.0

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