prawn-svg 0.35.1 → 0.36.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +3 -5
  3. data/README.md +4 -7
  4. data/lib/prawn/svg/attributes/opacity.rb +23 -8
  5. data/lib/prawn/svg/attributes/stroke.rb +7 -9
  6. data/lib/prawn/svg/attributes/transform.rb +1 -1
  7. data/lib/prawn/svg/calculators/pixels.rb +9 -22
  8. data/lib/prawn/svg/color.rb +58 -59
  9. data/lib/prawn/svg/css/font_parser.rb +46 -0
  10. data/lib/prawn/svg/document.rb +5 -1
  11. data/lib/prawn/svg/elements/base.rb +22 -30
  12. data/lib/prawn/svg/elements/gradient.rb +99 -74
  13. data/lib/prawn/svg/elements/line.rb +1 -1
  14. data/lib/prawn/svg/elements/marker.rb +2 -0
  15. data/lib/prawn/svg/elements/root.rb +1 -1
  16. data/lib/prawn/svg/elements/text_component.rb +3 -3
  17. data/lib/prawn/svg/funciri.rb +14 -0
  18. data/lib/prawn/svg/gradient_renderer.rb +313 -0
  19. data/lib/prawn/svg/length.rb +43 -0
  20. data/lib/prawn/svg/paint.rb +67 -0
  21. data/lib/prawn/svg/percentage.rb +24 -0
  22. data/lib/prawn/svg/properties.rb +208 -104
  23. data/lib/prawn/svg/renderer.rb +5 -0
  24. data/lib/prawn/svg/state.rb +5 -3
  25. data/lib/prawn/svg/transform_parser.rb +19 -13
  26. data/lib/prawn/svg/transform_utils.rb +37 -0
  27. data/lib/prawn/svg/version.rb +1 -1
  28. data/lib/prawn-svg.rb +7 -3
  29. data/prawn-svg.gemspec +4 -4
  30. data/spec/prawn/svg/attributes/opacity_spec.rb +27 -20
  31. data/spec/prawn/svg/attributes/transform_spec.rb +5 -2
  32. data/spec/prawn/svg/calculators/pixels_spec.rb +1 -2
  33. data/spec/prawn/svg/color_spec.rb +22 -52
  34. data/spec/prawn/svg/elements/base_spec.rb +9 -10
  35. data/spec/prawn/svg/elements/gradient_spec.rb +92 -36
  36. data/spec/prawn/svg/elements/marker_spec.rb +13 -15
  37. data/spec/prawn/svg/funciri_spec.rb +59 -0
  38. data/spec/prawn/svg/length_spec.rb +89 -0
  39. data/spec/prawn/svg/paint_spec.rb +96 -0
  40. data/spec/prawn/svg/pathable_spec.rb +3 -3
  41. data/spec/prawn/svg/pdfmatrix_spec.rb +60 -0
  42. data/spec/prawn/svg/percentage_spec.rb +60 -0
  43. data/spec/prawn/svg/properties_spec.rb +124 -107
  44. data/spec/prawn/svg/transform_parser_spec.rb +13 -13
  45. data/spec/sample_svg/gradient_stress_test.svg +115 -0
  46. metadata +17 -5
  47. data/lib/prawn/svg/extensions/additional_gradient_transforms.rb +0 -23
@@ -1,6 +1,6 @@
1
1
  class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base
2
2
  attr_reader :parent_gradient
3
- attr_reader :x1, :y1, :x2, :y2, :cx, :cy, :fx, :fy, :radius, :units, :stops, :transform_matrix
3
+ attr_reader :x1, :y1, :x2, :y2, :cx, :cy, :r, :fx, :fy, :fr, :units, :stops, :transform_matrix, :wrap
4
4
 
5
5
  TAG_NAME_TO_TYPE = {
6
6
  'linearGradient' => :linear,
@@ -12,6 +12,8 @@ class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base
12
12
  raise SkipElementQuietly if attributes['id'].nil?
13
13
 
14
14
  @parent_gradient = document.gradients[href_attribute[1..]] if href_attribute && href_attribute[0] == '#'
15
+ @transform_matrix = Matrix.identity(3)
16
+ @wrap = :pad
15
17
 
16
18
  assert_compatible_prawn_version
17
19
  load_gradient_configuration
@@ -24,57 +26,58 @@ class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base
24
26
  end
25
27
 
26
28
  def gradient_arguments(element)
27
- # Passing in a transformation matrix to the apply_transformations option is supported
28
- # by a monkey patch installed by prawn-svg. Prawn only sees this as a truthy variable.
29
- #
30
- # See Prawn::SVG::Extensions::AdditionalGradientTransforms for details.
31
- base_arguments = { stops: stops, apply_transformations: transform_matrix || true }
32
-
33
- arguments = specific_gradient_arguments(element)
34
- arguments&.merge(base_arguments)
29
+ bbox = element.bounding_box
30
+
31
+ if type == :radial
32
+ {
33
+ from: [fx, fy],
34
+ r1: fr,
35
+ to: [cx, cy],
36
+ r2: r,
37
+ stops: stops,
38
+ matrix: matrix_for_bounding_box(*bbox),
39
+ wrap: wrap,
40
+ bounding_box: bbox
41
+ }
42
+ else
43
+ {
44
+ from: [x1, y1],
45
+ to: [x2, y2],
46
+ stops: stops,
47
+ matrix: matrix_for_bounding_box(*bbox),
48
+ wrap: wrap,
49
+ bounding_box: bbox
50
+ }
51
+ end
52
+ end
53
+
54
+ def derive_attribute(name)
55
+ attributes[name] || parent_gradient&.derive_attribute(name)
35
56
  end
36
57
 
37
58
  private
38
59
 
39
- def specific_gradient_arguments(element)
60
+ def matrix_for_bounding_box(bounding_x1, bounding_y1, bounding_x2, bounding_y2)
40
61
  if units == :bounding_box
41
- bounding_x1, bounding_y1, bounding_x2, bounding_y2 = element.bounding_box
42
- return if bounding_y2.nil?
43
-
44
62
  width = bounding_x2 - bounding_x1
45
63
  height = bounding_y1 - bounding_y2
46
- end
47
-
48
- case [type, units]
49
- when [:linear, :bounding_box]
50
- from = [bounding_x1 + (width * x1), bounding_y1 - (height * y1)]
51
- to = [bounding_x1 + (width * x2), bounding_y1 - (height * y2)]
52
-
53
- { from: from, to: to }
54
-
55
- when [:linear, :user_space]
56
- { from: [x1, y1], to: [x2, y2] }
57
-
58
- when [:radial, :bounding_box]
59
- center = [bounding_x1 + (width * cx), bounding_y1 - (height * cy)]
60
- focus = [bounding_x1 + (width * fx), bounding_y1 - (height * fy)]
61
64
 
62
- # NOTE: Chrome, at least, implements radial bounding box radiuses as
63
- # having separate X and Y components, so in bounding box mode their
64
- # gradients come out as ovals instead of circles. PDF radial shading
65
- # doesn't have the option to do this, and it's confusing why the
66
- # Chrome user space gradients don't apply the same logic anyway.
67
- hypot = Math.sqrt((width * width) + (height * height))
68
- { from: focus, r1: 0, to: center, r2: radius * hypot }
69
-
70
- when [:radial, :user_space]
71
- { from: [fx, fy], r1: 0, to: [cx, cy], r2: radius }
65
+ bounding_box_to_user_space_matrix = Matrix[
66
+ [width, 0.0, bounding_x1],
67
+ [0.0, height, document.sizing.output_height - bounding_y1],
68
+ [0.0, 0.0, 1.0]
69
+ ]
72
70
 
71
+ svg_to_pdf_matrix * bounding_box_to_user_space_matrix * transform_matrix
73
72
  else
74
- raise 'unexpected type/unit system'
73
+ svg_to_pdf_matrix * transform_matrix
75
74
  end
76
75
  end
77
76
 
77
+ def svg_to_pdf_matrix
78
+ @svg_to_pdf_matrix ||= Matrix[[1.0, 0.0, 0.0], [0.0, -1.0, document.sizing.output_height], [0.0, 0.0, 1.0]]
79
+ end
80
+
78
81
  def type
79
82
  TAG_NAME_TO_TYPE.fetch(name)
80
83
  end
@@ -86,44 +89,47 @@ class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base
86
89
  end
87
90
 
88
91
  def load_gradient_configuration
89
- @units = attributes['gradientUnits'] == 'userSpaceOnUse' ? :user_space : :bounding_box
92
+ @units = derive_attribute('gradientUnits') == 'userSpaceOnUse' ? :user_space : :bounding_box
90
93
 
91
- if (transform = attributes['gradientTransform'])
92
- @transform_matrix = parse_transform_attribute(transform)
94
+ if (transform = derive_attribute('gradientTransform'))
95
+ @transform_matrix = parse_transform_attribute(transform, space: :svg)
93
96
  end
94
97
 
95
- if (spread_method = attributes['spreadMethod']) && spread_method != 'pad'
96
- warnings << "prawn-svg only currently supports the 'pad' spreadMethod attribute value"
98
+ if (spread_method = derive_attribute('spreadMethod'))
99
+ spread_method = spread_method.to_sym
100
+ @wrap = [:pad, :reflect, :repeat].include?(spread_method) ? spread_method : :pad
97
101
  end
98
102
  end
99
103
 
100
104
  def load_coordinates
101
105
  case [type, units]
102
106
  when [:linear, :bounding_box]
103
- @x1 = parse_zero_to_one(attributes['x1'], 0)
104
- @y1 = parse_zero_to_one(attributes['y1'], 0)
105
- @x2 = parse_zero_to_one(attributes['x2'], 1)
106
- @y2 = parse_zero_to_one(attributes['y2'], 0)
107
+ @x1 = percentage_or_proportion(derive_attribute('x1'), 0.0)
108
+ @y1 = percentage_or_proportion(derive_attribute('y1'), 0.0)
109
+ @x2 = percentage_or_proportion(derive_attribute('x2'), 1.0)
110
+ @y2 = percentage_or_proportion(derive_attribute('y2'), 0.0)
107
111
 
108
112
  when [:linear, :user_space]
109
- @x1 = x(attributes['x1'])
110
- @y1 = y(attributes['y1'])
111
- @x2 = x(attributes['x2'])
112
- @y2 = y(attributes['y2'])
113
+ @x1 = x(derive_attribute('x1'))
114
+ @y1 = y_pixels(derive_attribute('y1'))
115
+ @x2 = x(derive_attribute('x2'))
116
+ @y2 = y_pixels(derive_attribute('y2'))
113
117
 
114
118
  when [:radial, :bounding_box]
115
- @cx = parse_zero_to_one(attributes['cx'], 0.5)
116
- @cy = parse_zero_to_one(attributes['cy'], 0.5)
117
- @fx = parse_zero_to_one(attributes['fx'], cx)
118
- @fy = parse_zero_to_one(attributes['fy'], cy)
119
- @radius = parse_zero_to_one(attributes['r'], 0.5)
119
+ @cx = percentage_or_proportion(derive_attribute('cx'), 0.5)
120
+ @cy = percentage_or_proportion(derive_attribute('cy'), 0.5)
121
+ @r = percentage_or_proportion(derive_attribute('r'), 0.5)
122
+ @fx = percentage_or_proportion(derive_attribute('fx'), cx)
123
+ @fy = percentage_or_proportion(derive_attribute('fy'), cy)
124
+ @fr = percentage_or_proportion(derive_attribute('fr'), 0.0)
120
125
 
121
126
  when [:radial, :user_space]
122
- @cx = x(attributes['cx'] || '50%')
123
- @cy = y(attributes['cy'] || '50%')
124
- @fx = x(attributes['fx'] || attributes['cx'])
125
- @fy = y(attributes['fy'] || attributes['cy'])
126
- @radius = pixels(attributes['r'] || '50%')
127
+ @cx = x(derive_attribute('cx') || '50%')
128
+ @cy = y_pixels(derive_attribute('cy') || '50%')
129
+ @r = pixels(derive_attribute('r') || '50%')
130
+ @fx = x(derive_attribute('fx') || derive_attribute('cx'))
131
+ @fy = y_pixels(derive_attribute('fy') || derive_attribute('cy'))
132
+ @fr = pixels(derive_attribute('fr') || '0%')
127
133
 
128
134
  else
129
135
  raise 'unexpected type/unit system'
@@ -139,14 +145,14 @@ class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base
139
145
  element.name == 'stop' && element.attributes['offset']
140
146
  end
141
147
 
142
- @stops = stop_elements.each.with_object([]) do |child, result|
143
- offset = parse_zero_to_one(child.attributes['offset'])
148
+ @stops = stop_elements.each_with_object([]) do |child, result|
149
+ offset = percentage_or_proportion(child.attributes['offset']).clamp(0.0, 1.0)
144
150
 
145
151
  # Offsets must be strictly increasing (SVG 13.2.4)
146
- offset = result.last.first if result.last && result.last.first > offset
152
+ offset = result.last[:offset] if result.last && result.last[:offset] > offset
147
153
 
148
- if (color = Prawn::SVG::Color.css_color_to_prawn_color(child.properties.stop_color))
149
- result << [offset, color]
154
+ if (color = child.properties.stop_color&.value)
155
+ result << { offset: offset, color: color, opacity: (child.properties.stop_opacity || 1.0).clamp(0.0, 1.0) }
150
156
  end
151
157
  end
152
158
 
@@ -157,17 +163,36 @@ class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base
157
163
 
158
164
  @stops = parent_gradient.stops
159
165
  else
160
- stops.unshift([0, stops.first.last]) if stops.first.first.positive?
161
- stops.push([1, stops.last.last]) if stops.last.first < 1
166
+ if stops.first[:offset].positive?
167
+ start_stop = stops.first.dup
168
+ start_stop[:offset] = 0
169
+ stops.unshift(start_stop)
170
+ end
171
+
172
+ if stops.last[:offset] < 1
173
+ end_stop = stops.last.dup
174
+ end_stop[:offset] = 1
175
+ stops.push(end_stop)
176
+ end
162
177
  end
163
178
  end
164
179
 
165
- def parse_zero_to_one(string, default = 0)
180
+ def percentage_or_proportion(string, default = 0)
166
181
  string = string.to_s.strip
167
- return default if string == ''
182
+ percentage = false
183
+
184
+ if string[-1] == '%'
185
+ percentage = true
186
+ string = string[0..-2]
187
+ end
168
188
 
169
- value = string.to_f
170
- value /= 100.0 if string[-1..] == '%'
171
- [0.0, value, 1.0].sort[1]
189
+ value = Float(string, exception: false)
190
+ return default unless value
191
+
192
+ if percentage
193
+ value / 100.0
194
+ else
195
+ value
196
+ end
172
197
  end
173
198
  end
@@ -3,7 +3,7 @@ class Prawn::SVG::Elements::Line < Prawn::SVG::Elements::Base
3
3
 
4
4
  def parse
5
5
  # Lines are one dimensional, so cannot be filled.
6
- computed_properties.fill = 'none'
6
+ computed_properties.fill = Prawn::SVG::Paint.none
7
7
 
8
8
  @x1 = x_pixels(attributes['x1'] || 0)
9
9
  @y1 = y_pixels(attributes['y1'] || 0)
@@ -56,6 +56,8 @@ class Prawn::SVG::Elements::Marker < Prawn::SVG::Elements::Base
56
56
 
57
57
  new_state = state.dup
58
58
  new_state.computed_properties = computed_properties.dup
59
+ new_state.last_fill_opacity = element.state.last_fill_opacity
60
+ new_state.last_stroke_opacity = element.state.last_stroke_opacity
59
61
 
60
62
  container = Prawn::SVG::Elements::Container.new(document, nil, [], new_state)
61
63
  container.properties.compute_properties(new_state.computed_properties)
@@ -8,7 +8,7 @@ class Prawn::SVG::Elements::Root < Prawn::SVG::Elements::Base
8
8
  end
9
9
 
10
10
  def apply
11
- if [nil, 'inherit', 'none', 'currentColor'].include?(properties.fill)
11
+ if [nil, 'inherit'].include?(properties.fill) || properties.fill.none? || properties.fill.color == :currentcolor
12
12
  add_call 'fill_color', Prawn::SVG::Color.default_color(@document.color_mode).value
13
13
  end
14
14
 
@@ -46,7 +46,7 @@ class Prawn::SVG::Elements::TextComponent < Prawn::SVG::Elements::DepthFirstBase
46
46
  # text_anchor and dominant_baseline aren't Prawn options; we have to do some math to support them
47
47
  # and so we handle them in Prawn::SVG::Interface#rewrite_call_arguments
48
48
  opts = {
49
- size: computed_properties.numerical_font_size,
49
+ size: computed_properties.numeric_font_size,
50
50
  style: font&.subfamily,
51
51
  text_anchor: computed_properties.text_anchor
52
52
  }
@@ -222,8 +222,8 @@ class Prawn::SVG::Elements::TextComponent < Prawn::SVG::Elements::DepthFirstBase
222
222
  end
223
223
 
224
224
  def calculate_text_rendering_mode
225
- fill = computed_properties.fill != 'none'
226
- stroke = computed_properties.stroke != 'none'
225
+ fill = !computed_properties.fill.none? # rubocop:disable Style/InverseMethods
226
+ stroke = !computed_properties.stroke.none? # rubocop:disable Style/InverseMethods
227
227
 
228
228
  if fill && stroke
229
229
  :fill_stroke
@@ -0,0 +1,14 @@
1
+ Prawn::SVG::FuncIRI = Struct.new(:url) do
2
+ def self.parse(value)
3
+ case Prawn::SVG::CSS::ValuesParser.parse(value)
4
+ in [['url', [url]]]
5
+ new(url.strip)
6
+ else
7
+ nil
8
+ end
9
+ end
10
+
11
+ def to_s
12
+ "url(#{url})"
13
+ end
14
+ end
@@ -0,0 +1,313 @@
1
+ class Prawn::SVG::GradientRenderer
2
+ include Prawn::SVG::PDFMatrix
3
+
4
+ @mutex = Mutex.new
5
+ @counter = 0
6
+
7
+ def initialize(prawn, draw_type, from:, to:, stops:, matrix: nil, r1: nil, r2: nil, wrap: :pad, bounding_box: nil)
8
+ @prawn = prawn
9
+ @draw_type = draw_type
10
+ @from = from
11
+ @to = to
12
+ @bounding_box = bounding_box
13
+
14
+ if r1
15
+ @shading_type = 3
16
+ @coordinates = [*from, r1, *to, r2]
17
+ else
18
+ @shading_type = 2
19
+ @coordinates = [*from, *to]
20
+ end
21
+
22
+ @stop_offsets, @color_stops, @opacity_stops = process_stop_arguments(stops)
23
+ @gradient_matrix = matrix ? load_matrix(matrix) : Matrix.identity(3)
24
+ @wrap = wrap
25
+ end
26
+
27
+ def draw
28
+ key = self.class.next_key
29
+
30
+ # If we need transparency, add an ExtGState to the page and enable it.
31
+ if opacity_stops
32
+ prawn.page.ext_gstates["PSVG-ExtGState-#{key}"] = create_transparency_graphics_state
33
+ prawn.renderer.add_content("/PSVG-ExtGState-#{key} gs")
34
+ end
35
+
36
+ # Add pattern to the PDF page resources dictionary.
37
+ prawn.page.resources[:Pattern] ||= {}
38
+ prawn.page.resources[:Pattern]["PSVG-Pattern-#{key}"] = create_gradient_pattern
39
+
40
+ # Finally set the pattern with the drawing operator for fill/stroke.
41
+ prawn.send(:set_color_space, draw_type, :Pattern)
42
+ draw_operator = draw_type == :fill ? 'scn' : 'SCN'
43
+ prawn.renderer.add_content("/PSVG-Pattern-#{key} #{draw_operator}")
44
+ end
45
+
46
+ def self.next_key
47
+ @mutex.synchronize { @counter += 1 }
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :prawn, :draw_type, :shading_type, :coordinates, :from, :to,
53
+ :stop_offsets, :color_stops, :opacity_stops, :gradient_matrix, :wrap, :bounding_box
54
+
55
+ def process_stop_arguments(stops)
56
+ stop_offsets = []
57
+ color_stops = []
58
+ opacity_stops = []
59
+
60
+ transparency = false
61
+
62
+ stops.each do |stop|
63
+ opacity = stop[:opacity] || 1.0
64
+
65
+ transparency = true if opacity < 1
66
+
67
+ stop_offsets << stop[:offset]
68
+ color_stops << prawn.send(:normalize_color, stop[:color])
69
+ opacity_stops << [opacity]
70
+ end
71
+
72
+ opacity_stops = nil unless transparency
73
+
74
+ [stop_offsets, color_stops, opacity_stops]
75
+ end
76
+
77
+ def create_transparency_graphics_state
78
+ prawn.renderer.min_version(1.4)
79
+
80
+ repeat_count, repeat_offset, transform = compute_wrapping(wrap, from, to, current_pdf_translation)
81
+
82
+ transparency_group = prawn.ref!(
83
+ Type: :XObject,
84
+ Subtype: :Form,
85
+ BBox: prawn.state.page.dimensions,
86
+ Group: {
87
+ Type: :Group,
88
+ S: :Transparency,
89
+ I: true,
90
+ CS: :DeviceGray
91
+ },
92
+ Resources: {
93
+ Pattern: {
94
+ 'TGP01' => {
95
+ PatternType: 2,
96
+ Matrix: matrix_for_pdf(transform),
97
+ Shading: {
98
+ ShadingType: shading_type,
99
+ ColorSpace: :DeviceGray,
100
+ Coords: coordinates,
101
+ Domain: [0, repeat_count],
102
+ Function: create_shading_function(stop_offsets, opacity_stops, wrap, repeat_count, repeat_offset),
103
+ Extend: [true, true]
104
+ }
105
+ }
106
+ }
107
+ }
108
+ )
109
+
110
+ transparency_group.stream << begin
111
+ box = PDF::Core.real_params(prawn.state.page.dimensions)
112
+
113
+ <<~CMDS.strip
114
+ /Pattern cs
115
+ /TGP01 scn
116
+ #{box} re
117
+ f
118
+ CMDS
119
+ end
120
+
121
+ prawn.ref!(
122
+ Type: :ExtGState,
123
+ SMask: {
124
+ Type: :Mask,
125
+ S: :Luminosity,
126
+ G: transparency_group
127
+ },
128
+ AIS: false
129
+ )
130
+ end
131
+
132
+ def create_gradient_pattern
133
+ repeat_count, repeat_offset, transform = compute_wrapping(wrap, from, to, current_pdf_transform)
134
+
135
+ prawn.ref!(
136
+ PatternType: 2,
137
+ Matrix: matrix_for_pdf(transform),
138
+ Shading: {
139
+ ShadingType: shading_type,
140
+ ColorSpace: prawn.send(:color_space, color_stops.first),
141
+ Coords: coordinates,
142
+ Domain: [0, repeat_count],
143
+ Function: create_shading_function(stop_offsets, color_stops, wrap, repeat_count, repeat_offset),
144
+ Extend: [true, true]
145
+ }
146
+ )
147
+ end
148
+
149
+ def create_shading_function(offsets, stop_values, wrap = :pad, repeat_count = 1, repeat_offset = 0)
150
+ gradient_func = create_shading_function_for_stops(offsets, stop_values)
151
+
152
+ # Return the gradient function if there is no need to repeat.
153
+ return gradient_func if wrap == :pad
154
+
155
+ even_odd_encode = wrap == :reflect ? [[0, 1], [1, 0]] : [[0, 1], [0, 1]]
156
+ encode = repeat_count.times.flat_map { |num| even_odd_encode[(num + repeat_offset) % 2] }
157
+
158
+ prawn.ref!(
159
+ FunctionType: 3, # stitching function
160
+ Domain: [0, repeat_count],
161
+ Functions: Array.new(repeat_count, gradient_func),
162
+ Bounds: Range.new(1, repeat_count - 1).to_a,
163
+ Encode: encode
164
+ )
165
+ end
166
+
167
+ def create_shading_function_for_stops(offsets, stop_values)
168
+ linear_funcs = stop_values.each_cons(2).map do |c0, c1|
169
+ prawn.ref!(FunctionType: 2, Domain: [0.0, 1.0], C0: c0, C1: c1, N: 1.0)
170
+ end
171
+
172
+ # If there's only two stops, we can use the single shader.
173
+ return linear_funcs.first if linear_funcs.length == 1
174
+
175
+ # Otherwise we stitch the multiple shaders together.
176
+ prawn.ref!(
177
+ FunctionType: 3, # stitching function
178
+ Domain: [0.0, 1.0],
179
+ Functions: linear_funcs,
180
+ Bounds: offsets[1..-2],
181
+ Encode: [0.0, 1.0] * linear_funcs.length
182
+ )
183
+ end
184
+
185
+ def current_pdf_transform
186
+ @current_pdf_transform ||= load_matrix(
187
+ prawn.current_transformation_matrix_with_translation(*prawn.bounds.anchor)
188
+ )
189
+ end
190
+
191
+ def current_pdf_translation
192
+ @current_pdf_translation ||= begin
193
+ bounds_x, bounds_y = prawn.bounds.anchor
194
+ Matrix[[1, 0, bounds_x], [0, 1, bounds_y], [0, 0, 1]]
195
+ end
196
+ end
197
+
198
+ def bounding_box_corners(matrix)
199
+ if bounding_box
200
+ transformed_corners(gradient_matrix.inverse, *bounding_box)
201
+ else
202
+ transformed_corners(matrix.inverse, *prawn_bounding_box)
203
+ end
204
+ end
205
+
206
+ def prawn_bounding_box
207
+ [*prawn.bounds.top_left, *prawn.bounds.bottom_right]
208
+ end
209
+
210
+ def transformed_corners(matrix, left, top, right, bottom)
211
+ [
212
+ matrix * Vector[left, top, 1.0],
213
+ matrix * Vector[left, bottom, 1.0],
214
+ matrix * Vector[right, top, 1.0],
215
+ matrix * Vector[right, bottom, 1.0]
216
+ ]
217
+ end
218
+
219
+ def compute_wrapping(wrap, from, to, page_transform)
220
+ matrix = page_transform * gradient_matrix
221
+
222
+ return [1, 0, matrix] if wrap == :pad
223
+
224
+ from = Vector[from[0], from[1], 1.0]
225
+ to = Vector[to[0], to[1], 1.0]
226
+
227
+ # Transform the bounding box into gradient space where lines are straight
228
+ # and circles are round.
229
+ box_corners = bounding_box_corners(matrix)
230
+
231
+ repeat_count, repeat_offset, delta = if shading_type == 2 # Linear
232
+ project_bounding_box_for_linear(from, to, box_corners)
233
+ else # Radial
234
+ project_bounding_box_for_radial(from, to, box_corners)
235
+ end
236
+
237
+ repeat_count = [repeat_count, 50].min
238
+
239
+ wrap_transform = translation_matrix(delta[0], delta[1]) *
240
+ translation_matrix(from[0], from[1]) *
241
+ scale_matrix(repeat_count) *
242
+ translation_matrix(-from[0], -from[1])
243
+
244
+ [repeat_count, repeat_offset, matrix * wrap_transform]
245
+ end
246
+
247
+ def project_bounding_box_for_linear(from, to, box_corners)
248
+ ab = to - from
249
+
250
+ # Project each corner of the bounding box onto the line made by the
251
+ # gradient. The formula for projecting a point C onto a line formed from
252
+ # point A to point B is as follows:
253
+ #
254
+ # AB = B - A
255
+ # AC = C - A
256
+ # t = (AB dot AC) / (AB dot AB)
257
+ # P = A + (AB * t)
258
+ #
259
+ # We don't actually need the final point P, we only need the parameter "t",
260
+ # so that we know how many times to repeat the gradient.
261
+ t_for_corners = box_corners.map do |corner|
262
+ ac = corner - from
263
+ ab.dot(ac) / ab.dot(ab)
264
+ end
265
+
266
+ t_min, t_max = t_for_corners.minmax
267
+
268
+ repeat_count = (t_max - t_min).ceil + 1
269
+
270
+ shift_count = t_min.floor
271
+ delta = ab * shift_count
272
+ repeat_offset = shift_count % 2
273
+
274
+ [repeat_count, repeat_offset, delta]
275
+ end
276
+
277
+ def project_bounding_box_for_radial(from, to, box_corners)
278
+ r1 = coordinates[2]
279
+ r2 = coordinates[5]
280
+
281
+ # For radial gradients, the approach is similar. We need to find "t" to
282
+ # know how far along the gradient line each corner of the bounding box
283
+ # lies. Only this time we need to solve the simultaneous equation for the
284
+ # point on both inner and outer circles.
285
+ #
286
+ # You can find the derivation for this here:
287
+ # https://github.com/libpixman/pixman/blob/85467ec308f8621a5410c007491797b7b1847601/pixman/pixman-radial-gradient.c#L162-L241
288
+ #
289
+ # Do this for all 4 corners and pick the biggest number to repeat.
290
+ t = box_corners.reduce(1) do |max, corner|
291
+ cdx, cdy = *(to - from)
292
+ pdx, pdy = *(corner - from)
293
+ dr = r2 - r1
294
+
295
+ a = cdx.abs2 + cdy.abs2 - dr.abs2
296
+ b = (pdx * cdx) + (pdy * cdy) + (r1 * dr)
297
+ c = pdx.abs2 + pdy.abs2 - r1.abs2
298
+ det_root = Math.sqrt(b.abs2 - (a * c))
299
+
300
+ t0 = (b + det_root) / a
301
+ t1 = (b - det_root) / a
302
+
303
+ [t0, t1, max].max
304
+ end
305
+
306
+ repeat_count = t.ceil
307
+
308
+ delta = [0.0, 0.0]
309
+ repeat_offset = 0
310
+
311
+ [repeat_count, repeat_offset, delta]
312
+ end
313
+ end
@@ -0,0 +1,43 @@
1
+ module Prawn::SVG
2
+ Length = Struct.new(:value, :unit)
3
+
4
+ class Length
5
+ REGEXP = /\A([+-]?\d*(?:\.\d+)?)(em|rem|ex|px|in|cm|mm|pt|pc)?\z/i.freeze
6
+
7
+ def self.parse(value, positive_only: false)
8
+ if (matches = value.match(REGEXP))
9
+ number = Float(matches[1], exception: false)
10
+ new(number, matches[2]&.downcase&.to_sym) if number && (!positive_only || number >= 0)
11
+ end
12
+ end
13
+
14
+ def to_f
15
+ value
16
+ end
17
+
18
+ def to_s
19
+ "#{value}#{unit}"
20
+ end
21
+
22
+ def to_pixels(_axis_length, font_size)
23
+ case unit
24
+ when :em
25
+ value * font_size
26
+ when :rem
27
+ value * Properties::EM
28
+ when :ex
29
+ value * (font_size / 2.0) # we don't have access to the x-height, so this is an approximation approved by the CSS spec
30
+ when :pc
31
+ value * 15 # according to http://www.w3.org/TR/SVG11/coords.html
32
+ when :in
33
+ value * 72
34
+ when :cm
35
+ value * 10 * (72 / 25.4)
36
+ when :mm
37
+ value * (72 / 25.4)
38
+ else
39
+ value
40
+ end
41
+ end
42
+ end
43
+ end