prawn-svg 0.35.0 → 0.36.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 (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 +25 -7
  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