mini_magick 3.8.1 → 4.9.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +5 -5
  2. data/lib/mini_gmagick.rb +1 -0
  3. data/lib/mini_magick/configuration.rb +198 -0
  4. data/lib/mini_magick/image/info.rb +192 -0
  5. data/lib/mini_magick/image.rb +456 -341
  6. data/lib/mini_magick/shell.rb +81 -0
  7. data/lib/mini_magick/tool/animate.rb +14 -0
  8. data/lib/mini_magick/tool/compare.rb +14 -0
  9. data/lib/mini_magick/tool/composite.rb +14 -0
  10. data/lib/mini_magick/tool/conjure.rb +14 -0
  11. data/lib/mini_magick/tool/convert.rb +14 -0
  12. data/lib/mini_magick/tool/display.rb +14 -0
  13. data/lib/mini_magick/tool/identify.rb +14 -0
  14. data/lib/mini_magick/tool/import.rb +14 -0
  15. data/lib/mini_magick/tool/magick.rb +14 -0
  16. data/lib/mini_magick/tool/mogrify.rb +14 -0
  17. data/lib/mini_magick/tool/mogrify_restricted.rb +15 -0
  18. data/lib/mini_magick/tool/montage.rb +14 -0
  19. data/lib/mini_magick/tool/stream.rb +14 -0
  20. data/lib/mini_magick/tool.rb +297 -0
  21. data/lib/mini_magick/utilities.rb +23 -50
  22. data/lib/mini_magick/version.rb +5 -5
  23. data/lib/mini_magick.rb +54 -65
  24. metadata +49 -51
  25. data/lib/mini_magick/command_builder.rb +0 -94
  26. data/lib/mini_magick/errors.rb +0 -4
  27. data/spec/files/actually_a_gif.jpg +0 -0
  28. data/spec/files/animation.gif +0 -0
  29. data/spec/files/composited.jpg +0 -0
  30. data/spec/files/erroneous.jpg +0 -0
  31. data/spec/files/layers.psd +0 -0
  32. data/spec/files/leaves (spaced).tiff +0 -0
  33. data/spec/files/not_an_image.php +0 -1
  34. data/spec/files/png.png +0 -0
  35. data/spec/files/simple-minus.gif +0 -0
  36. data/spec/files/simple.gif +0 -0
  37. data/spec/files/trogdor.jpg +0 -0
  38. data/spec/files/trogdor_capitalized.JPG +0 -0
  39. data/spec/lib/mini_magick/command_builder_spec.rb +0 -153
  40. data/spec/lib/mini_magick/image_spec.rb +0 -499
  41. data/spec/lib/mini_magick_spec.rb +0 -63
  42. data/spec/spec_helper.rb +0 -29
@@ -0,0 +1,81 @@
1
+ require "timeout"
2
+ require "benchmark"
3
+
4
+ module MiniMagick
5
+ ##
6
+ # Sends commands to the shell (more precisely, it sends commands directly to
7
+ # the operating system).
8
+ #
9
+ # @private
10
+ #
11
+ class Shell
12
+
13
+ def run(command, options = {})
14
+ stdout, stderr, status = execute(command, stdin: options[:stdin])
15
+
16
+ if status != 0 && options.fetch(:whiny, MiniMagick.whiny)
17
+ fail MiniMagick::Error, "`#{command.join(" ")}` failed with error:\n#{stderr}"
18
+ end
19
+
20
+ $stderr.print(stderr) unless options[:stderr] == false
21
+
22
+ [stdout, stderr, status]
23
+ end
24
+
25
+ def execute(command, options = {})
26
+ stdout, stderr, status =
27
+ log(command.join(" ")) do
28
+ send("execute_#{MiniMagick.shell_api.gsub("-", "_")}", command, options)
29
+ end
30
+
31
+ [stdout, stderr, status.exitstatus]
32
+ rescue Errno::ENOENT, IOError
33
+ ["", "executable not found: \"#{command.first}\"", 127]
34
+ end
35
+
36
+ private
37
+
38
+ def execute_open3(command, options = {})
39
+ require "open3"
40
+
41
+ # We would ideally use Open3.capture3, but it wouldn't allow us to
42
+ # terminate the command after timing out.
43
+ Open3.popen3(*command) do |in_w, out_r, err_r, thread|
44
+ [in_w, out_r, err_r].each(&:binmode)
45
+ stdout_reader = Thread.new { out_r.read }
46
+ stderr_reader = Thread.new { err_r.read }
47
+ begin
48
+ in_w.write options[:stdin].to_s
49
+ rescue Errno::EPIPE
50
+ end
51
+ in_w.close
52
+
53
+ begin
54
+ Timeout.timeout(MiniMagick.timeout) { thread.join }
55
+ rescue Timeout::Error
56
+ Process.kill("TERM", thread.pid) rescue nil
57
+ Process.waitpid(thread.pid) rescue nil
58
+ raise Timeout::Error, "MiniMagick command timed out: #{command}"
59
+ end
60
+
61
+ [stdout_reader.value, stderr_reader.value, thread.value]
62
+ end
63
+ end
64
+
65
+ def execute_posix_spawn(command, options = {})
66
+ require "posix-spawn"
67
+ child = POSIX::Spawn::Child.new(*command, input: options[:stdin].to_s, timeout: MiniMagick.timeout)
68
+ [child.out, child.err, child.status]
69
+ rescue POSIX::Spawn::TimeoutExceeded
70
+ raise Timeout::Error, "MiniMagick command timed out: #{command}"
71
+ end
72
+
73
+ def log(command, &block)
74
+ value = nil
75
+ duration = Benchmark.realtime { value = block.call }
76
+ MiniMagick.logger.debug "[%.2fs] %s" % [duration, command]
77
+ value
78
+ end
79
+
80
+ end
81
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/animate.php
5
+ #
6
+ class Animate < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("animate", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/compare.php
5
+ #
6
+ class Compare < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("compare", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/composite.php
5
+ #
6
+ class Composite < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("composite", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/conjure.php
5
+ #
6
+ class Conjure < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("conjure", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/convert.php
5
+ #
6
+ class Convert < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("convert", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/display.php
5
+ #
6
+ class Display < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("display", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/identify.php
5
+ #
6
+ class Identify < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("identify", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/import.php
5
+ #
6
+ class Import < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("import", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/command-line-processing.php
5
+ #
6
+ class Magick < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("magick", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/mogrify.php
5
+ #
6
+ class Mogrify < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("mogrify", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ require "mini_magick/tool/mogrify"
2
+
3
+ module MiniMagick
4
+ class Tool
5
+ ##
6
+ # @see http://www.imagemagick.org/script/mogrify.php
7
+ #
8
+ class MogrifyRestricted < Mogrify
9
+ def format(*args)
10
+ fail NoMethodError,
11
+ "you must call #format on a MiniMagick::Image directly"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/montage.php
5
+ #
6
+ class Montage < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("montage", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module MiniMagick
2
+ class Tool
3
+ ##
4
+ # @see http://www.imagemagick.org/script/stream.php
5
+ #
6
+ class Stream < MiniMagick::Tool
7
+
8
+ def initialize(*args)
9
+ super("stream", *args)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,297 @@
1
+ require "mini_magick/shell"
2
+
3
+ module MiniMagick
4
+ ##
5
+ # Abstract class that wraps command-line tools. It shouldn't be used directly,
6
+ # but through one of its subclasses (e.g. {MiniMagick::Tool::Mogrify}). Use
7
+ # this class if you want to be closer to the metal and execute ImageMagick
8
+ # commands directly, but still with a nice Ruby interface.
9
+ #
10
+ # @example
11
+ # MiniMagick::Tool::Mogrify.new do |builder|
12
+ # builder.resize "500x500"
13
+ # builder << "path/to/image.jpg"
14
+ # end
15
+ #
16
+ class Tool
17
+
18
+ CREATION_OPERATORS = %w[xc canvas logo rose gradient radial-gradient plasma
19
+ pattern text pango]
20
+
21
+ ##
22
+ # Aside from classic instantiation, it also accepts a block, and then
23
+ # executes the command in the end.
24
+ #
25
+ # @example
26
+ # version = MiniMagick::Tool::Identify.new { |b| b.version }
27
+ # puts version
28
+ #
29
+ # @return [MiniMagick::Tool, String] If no block is given, returns an
30
+ # instance of the tool, if block is given, returns the output of the
31
+ # command.
32
+ #
33
+ def self.new(*args)
34
+ instance = super(*args)
35
+
36
+ if block_given?
37
+ yield instance
38
+ instance.call
39
+ else
40
+ instance
41
+ end
42
+ end
43
+
44
+ # @private
45
+ attr_reader :name, :args
46
+
47
+ # @param whiny [Boolean] Whether to raise errors on exit codes different
48
+ # than 0.
49
+ # @example
50
+ # MiniMagick::Tool::Identify.new(whiny: false) do |identify|
51
+ # identify.help # returns exit status 1, which would otherwise throw an error
52
+ # end
53
+ def initialize(name, options = {})
54
+ warn "MiniMagick::Tool.new(false) is deprecated and will be removed in MiniMagick 5, use MiniMagick::Tool.new(whiny: false) instead." if !options.is_a?(Hash)
55
+
56
+ @name = name
57
+ @args = []
58
+ @whiny = options.is_a?(Hash) ? options.fetch(:whiny, MiniMagick.whiny) : options
59
+ end
60
+
61
+ ##
62
+ # Executes the command that has been built up.
63
+ #
64
+ # @example
65
+ # mogrify = MiniMagick::Tool::Mogrify.new
66
+ # mogrify.resize("500x500")
67
+ # mogrify << "path/to/image.jpg"
68
+ # mogrify.call # executes `mogrify -resize 500x500 path/to/image.jpg`
69
+ #
70
+ # @example
71
+ # mogrify = MiniMagick::Tool::Mogrify.new
72
+ # # build the command
73
+ # mogrify.call do |stdout, stderr, status|
74
+ # # ...
75
+ # end
76
+ #
77
+ # @yield [Array] Optionally yields stdout, stderr, and exit status
78
+ #
79
+ # @return [String] Returns the output of the command
80
+ #
81
+ def call(*args)
82
+ options = args[-1].is_a?(Hash) ? args.pop : {}
83
+ warn "Passing whiny to MiniMagick::Tool#call is deprecated and will be removed in MiniMagick 5, use MiniMagick::Tool.new(whiny: false) instead." if args.any?
84
+ whiny = args.fetch(0, @whiny)
85
+
86
+ options[:whiny] = whiny
87
+ options[:stderr] = false if block_given?
88
+
89
+ shell = MiniMagick::Shell.new
90
+ stdout, stderr, status = shell.run(command, options)
91
+ yield stdout, stderr, status if block_given?
92
+
93
+ stdout.chomp("\n")
94
+ end
95
+
96
+ ##
97
+ # The currently built-up command.
98
+ #
99
+ # @return [Array<String>]
100
+ #
101
+ # @example
102
+ # mogrify = MiniMagick::Tool::Mogrify.new
103
+ # mogrify.resize "500x500"
104
+ # mogrify.contrast
105
+ # mogrify.command #=> ["mogrify", "-resize", "500x500", "-contrast"]
106
+ #
107
+ def command
108
+ [*executable, *args]
109
+ end
110
+
111
+ ##
112
+ # The executable used for this tool. Respects
113
+ # {MiniMagick::Configuration#cli}, {MiniMagick::Configuration#cli_path},
114
+ # and {MiniMagick::Configuration#cli_prefix}.
115
+ #
116
+ # @return [Array<String>]
117
+ #
118
+ # @example
119
+ # MiniMagick.configure { |config| config.cli = :graphicsmagick }
120
+ # identify = MiniMagick::Tool::Identify.new
121
+ # identify.executable #=> ["gm", "identify"]
122
+ #
123
+ # @example
124
+ # MiniMagick.configure do |config|
125
+ # config.cli = :graphicsmagick
126
+ # config.cli_prefix = ['firejail', '--force']
127
+ # end
128
+ # identify = MiniMagick::Tool::Identify.new
129
+ # identify.executable #=> ["firejail", "--force", "gm", "identify"]
130
+ #
131
+ def executable
132
+ exe = [name]
133
+ exe.unshift "magick" if MiniMagick.imagemagick7? && name != "magick"
134
+ exe.unshift "gm" if MiniMagick.graphicsmagick?
135
+ exe.unshift File.join(MiniMagick.cli_path, exe.shift) if MiniMagick.cli_path
136
+ Array(MiniMagick.cli_prefix).reverse_each { |p| exe.unshift p } if MiniMagick.cli_prefix
137
+ exe
138
+ end
139
+
140
+ ##
141
+ # Appends raw options, useful for appending image paths.
142
+ #
143
+ # @return [self]
144
+ #
145
+ def <<(arg)
146
+ args << arg.to_s
147
+ self
148
+ end
149
+
150
+ ##
151
+ # Merges a list of raw options.
152
+ #
153
+ # @return [self]
154
+ #
155
+ def merge!(new_args)
156
+ new_args.each { |arg| self << arg }
157
+ self
158
+ end
159
+
160
+ ##
161
+ # Changes the last operator to its "plus" form.
162
+ #
163
+ # @example
164
+ # MiniMagick::Tool::Mogrify.new do |mogrify|
165
+ # mogrify.antialias.+
166
+ # mogrify.distort.+("Perspective", "0,0,4,5 89,0,45,46")
167
+ # end
168
+ # # executes `mogrify +antialias +distort Perspective '0,0,4,5 89,0,45,46'`
169
+ #
170
+ # @return [self]
171
+ #
172
+ def +(*values)
173
+ args[-1] = args[-1].sub(/^-/, '+')
174
+ self.merge!(values)
175
+ self
176
+ end
177
+
178
+ ##
179
+ # Create an ImageMagick stack in the command (surround.
180
+ #
181
+ # @example
182
+ # MiniMagick::Tool::Convert.new do |convert|
183
+ # convert << "wand.gif"
184
+ # convert.stack do |stack|
185
+ # stack << "wand.gif"
186
+ # stack.rotate(30)
187
+ # end
188
+ # convert.append.+
189
+ # convert << "images.gif"
190
+ # end
191
+ # # executes `convert wand.gif \( wizard.gif -rotate 30 \) +append images.gif`
192
+ #
193
+ def stack
194
+ self << "("
195
+ yield self
196
+ self << ")"
197
+ end
198
+
199
+ ##
200
+ # Adds ImageMagick's pseudo-filename `-` for standard input.
201
+ #
202
+ # @example
203
+ # identify = MiniMagick::Tool::Identify.new
204
+ # identify.stdin
205
+ # identify.call(stdin: image_content)
206
+ # # executes `identify -` with the given standard input
207
+ #
208
+ def stdin
209
+ self << "-"
210
+ end
211
+
212
+ ##
213
+ # Adds ImageMagick's pseudo-filename `-` for standard output.
214
+ #
215
+ # @example
216
+ # content = MiniMagick::Tool::Convert.new do |convert|
217
+ # convert << "input.jpg"
218
+ # convert.auto_orient
219
+ # convert.stdout
220
+ # end
221
+ # # executes `convert input.jpg -auto-orient -` which returns file contents
222
+ #
223
+ def stdout
224
+ self << "-"
225
+ end
226
+
227
+ ##
228
+ # Define creator operator methods
229
+ #
230
+ # mogrify = MiniMagick::Tool.new("mogrify")
231
+ # mogrify.canvas("khaki")
232
+ # mogrify.command.join(" ") #=> "mogrify canvas:khaki"
233
+ #
234
+ CREATION_OPERATORS.each do |operator|
235
+ define_method(operator.gsub('-', '_')) do |value = nil|
236
+ self << "#{operator}:#{value}"
237
+ self
238
+ end
239
+ end
240
+
241
+ ##
242
+ # This option is a valid ImageMagick option, but it's also a Ruby method,
243
+ # so we need to override it so that it correctly acts as an option method.
244
+ #
245
+ def clone(*args)
246
+ self << '-clone'
247
+ self.merge!(args)
248
+ self
249
+ end
250
+
251
+ ##
252
+ # Any undefined method will be transformed into a CLI option
253
+ #
254
+ # mogrify = MiniMagick::Tool.new("mogrify")
255
+ # mogrify.adaptive_blur("...")
256
+ # mogrify.foo_bar
257
+ # mogrify.command.join(" ") "mogrify -adaptive-blur ... -foo-bar"
258
+ #
259
+ def method_missing(name, *args)
260
+ option = "-#{name.to_s.tr('_', '-')}"
261
+ self << option
262
+ self.merge!(args)
263
+ self
264
+ end
265
+
266
+ def self.option_methods
267
+ @option_methods ||= (
268
+ tool = new(whiny: false)
269
+ tool << "-help"
270
+ help_page = tool.call(stderr: false)
271
+
272
+ cli_options = help_page.scan(/^\s+-[a-z\-]+/).map(&:strip)
273
+ if tool.name == "mogrify" && MiniMagick.graphicsmagick?
274
+ # These options were undocumented before 2015-06-14 (see gm bug 302)
275
+ cli_options |= %w[-box -convolve -gravity -linewidth -mattecolor -render -shave]
276
+ end
277
+
278
+ cli_options.map { |o| o[1..-1].tr('-','_') }
279
+ )
280
+ end
281
+
282
+ end
283
+ end
284
+
285
+ require "mini_magick/tool/animate"
286
+ require "mini_magick/tool/compare"
287
+ require "mini_magick/tool/composite"
288
+ require "mini_magick/tool/conjure"
289
+ require "mini_magick/tool/convert"
290
+ require "mini_magick/tool/display"
291
+ require "mini_magick/tool/identify"
292
+ require "mini_magick/tool/import"
293
+ require "mini_magick/tool/magick"
294
+ require "mini_magick/tool/mogrify"
295
+ require "mini_magick/tool/mogrify_restricted"
296
+ require "mini_magick/tool/montage"
297
+ require "mini_magick/tool/stream"
@@ -1,62 +1,35 @@
1
- require 'rbconfig'
2
- require 'shellwords'
3
- require 'pathname'
1
+ require "tempfile"
4
2
 
5
3
  module MiniMagick
4
+ # @private
6
5
  module Utilities
7
- class << self
8
- # Cross-platform way of finding an executable in the $PATH.
9
- #
10
- # which('ruby') #=> /usr/bin/ruby
11
- def which(cmd)
12
- exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
13
- ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
14
- exts.each do |ext|
15
- exe = File.join(path, "#{cmd}#{ext}")
16
- return exe if File.executable? exe
17
- end
18
- end
19
- nil
20
- end
21
6
 
22
- # Finds out if the host OS is windows
23
- def windows?
24
- RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
25
- end
7
+ module_function
26
8
 
27
- def escape(value)
28
- if windows?
29
- windows_escape(value)
30
- else
31
- shell_escape(value)
32
- end
33
- end
34
-
35
- def shell_escape(value)
36
- Shellwords.escape(value)
37
- end
38
-
39
- def windows_escape(value)
40
- # For Windows, ^ is the escape char, equivalent to \ in Unix.
41
- escaped = value.gsub(/\^/, '^^').gsub(/>/, '^>')
42
- if escaped !~ /^".+"$/ && escaped.include?("'")
43
- escaped.inspect
44
- else
45
- escaped
9
+ ##
10
+ # Cross-platform way of finding an executable in the $PATH.
11
+ #
12
+ # @example
13
+ # MiniMagick::Utilities.which('ruby') #=> "/usr/bin/ruby"
14
+ #
15
+ def which(cmd)
16
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
17
+ ENV.fetch('PATH').split(File::PATH_SEPARATOR).each do |path|
18
+ exts.each do |ext|
19
+ exe = File.join(path, "#{cmd}#{ext}")
20
+ return exe if File.executable? exe
46
21
  end
47
22
  end
23
+ nil
24
+ end
48
25
 
49
- def path(path)
50
- if windows?
51
- # For Windows, if a path contains space char, you need to quote it,
52
- # otherwise you SHOULD NOT quote it. If you quote a path that does
53
- # not contains space, it will not work.
54
- pathname = Pathname.new(path).to_s
55
- path.include?(' ') ? pathname.inspect : pathname
56
- else
57
- path
58
- end
26
+ def tempfile(extension)
27
+ Tempfile.new(["mini_magick", extension]).tap do |tempfile|
28
+ tempfile.binmode
29
+ yield tempfile if block_given?
30
+ tempfile.close
59
31
  end
60
32
  end
33
+
61
34
  end
62
35
  end
@@ -1,15 +1,15 @@
1
1
  module MiniMagick
2
2
  ##
3
- # Returns the version of the currently loaded MiniMagick as
4
- # a <tt>Gem::Version</tt>.
3
+ # @return [Gem::Version]
4
+ #
5
5
  def self.version
6
6
  Gem::Version.new VERSION::STRING
7
7
  end
8
8
 
9
9
  module VERSION
10
- MAJOR = 3
11
- MINOR = 8
12
- TINY = 1
10
+ MAJOR = 4
11
+ MINOR = 9
12
+ TINY = 4
13
13
  PRE = nil
14
14
 
15
15
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.')