crabgrass_media 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,331 @@
1
+ #
2
+ # A class to transmogrify a media asset from one form to another.
3
+ #
4
+ # All such media transformations are handled by instances of this class.
5
+ #
6
+
7
+ require 'fileutils'
8
+ require 'active_support/core_ext/module/delegation.rb'
9
+ require 'active_support/core_ext/module/attribute_accessors.rb'
10
+ require 'logger'
11
+
12
+ module Media
13
+ class Transmogrifier
14
+
15
+ # singleton logger, only set via class, access via class and instance
16
+ mattr_accessor :logger, instance_writer: false do
17
+ Logger.new(STDERR)
18
+ end
19
+
20
+ mattr_accessor :verbose, instance_writer: false
21
+ mattr_accessor :suppress_errors, instance_writer: false
22
+
23
+ delegate :debug, :info, :warning, :error, to: :logger
24
+
25
+
26
+ attr_accessor :name
27
+
28
+ attr_accessor :input
29
+ attr_accessor :input_file
30
+ attr_accessor :input_type
31
+
32
+ attr_accessor :output # maybe some day we return raw output via url?
33
+ attr_accessor :output_type # desired mime type of the output
34
+ attr_accessor :output_file # desired file location of the output
35
+
36
+ attr_accessor :options
37
+
38
+ attr_accessor :command_output # output of last command run
39
+
40
+ #
41
+ # takes a has of options, some of which are required:
42
+ #
43
+ # - :input_file or (:input and :input_type)
44
+ # - :output_file or :output_type
45
+ #
46
+ def initialize(options=nil)
47
+ self.name = self.class.to_s
48
+ # we keep an instance in the transmogrifier list.
49
+ # They only need to have the name set.
50
+ return if options.nil?
51
+
52
+ options = options.dup
53
+ self.input = options.delete(:input)
54
+ self.input_file = options.delete(:input_file)
55
+ self.input_type = options.delete(:input_type)
56
+ self.output_file = options.delete(:output_file)
57
+ self.output_type = options.delete(:output_type)
58
+ self.options = options
59
+
60
+
61
+
62
+ if input and input_type.nil?
63
+ raise ArgumentError.new('input_type required if input specified')
64
+ elsif input and input_file.nil?
65
+ self.input_file = Media::TempFile.new(input, input_type)
66
+ elsif input and input_file
67
+ raise ArgumentError.new('cannot have both input and input_file')
68
+ elsif input_file and input_type.nil?
69
+ self.input_type = Media::MimeType.mime_type_from_extension(input_file)
70
+ elsif input.nil? and input_file.nil?
71
+ raise ArgumentError.new('input or input_file is required')
72
+ end
73
+
74
+ if output_file.nil? and output_type.nil?
75
+ raise ArgumentError.new('output_file or output_type is required')
76
+ elsif output_file.nil?
77
+ self.output_file = Media::TempFile.new(nil, output_type)
78
+ elsif output_type.nil?
79
+ self.output_type = Media::MimeType.mime_type_from_extension(output_file)
80
+ end
81
+
82
+ debug self.class.name + " converting" +
83
+ " #{input_file } ( #{input_type} ) to" +
84
+ " #{output_file} ( #{output_type} )"
85
+
86
+ set_temporary_outfile
87
+
88
+ end
89
+
90
+ def self.inherited(base)
91
+ add(base.new)
92
+ end
93
+
94
+ ##
95
+ ## CLASS METHODS
96
+ ##
97
+
98
+ def self.list(); @@list ||= {}; end
99
+
100
+ # maps mine type to an array of transmogrifiers that
101
+ # take that type as an imput
102
+ def self.input_map; @@input_map ||= Hash.new([]); end
103
+
104
+ # maps mine type to an array of transmogrifiers that
105
+ # produce that type as an output
106
+ def self.output_map; @@output_map ||= Hash.new([]); end
107
+
108
+ def self.add(trans)
109
+ self.list[trans.name] ||= trans
110
+ end
111
+
112
+ #
113
+ # returns transmogrifier class, if any, that can tranform input_type
114
+ # into output_type
115
+ #
116
+ def self.find_class(input_type, output_type)
117
+ transmog = list.values.
118
+ select{|tm| tm.converts_from?(input_type)}.
119
+ select{|tm| tm.converts_to?(output_type)}.
120
+ select{|tm| tm.available?}.
121
+ first
122
+ return transmog.class if transmog
123
+ logger.error 'could not find a transmogrifier for "%s" -> "%s"' %
124
+ [input_type, output_type]
125
+ return nil
126
+ end
127
+
128
+ def converts_from?(input_type)
129
+ input_types.include? input_type
130
+ end
131
+
132
+ def converts_to?(output_type)
133
+ output_types.include? output_type
134
+ end
135
+
136
+
137
+ #
138
+ #def self.simple_type(mime_type)
139
+ # mime_type.gsub(/\/x\-/,'/') if mime_type
140
+ #end
141
+
142
+ ##
143
+ ## Helpers
144
+ ##
145
+
146
+ #
147
+ # runs a shell command, passing each line that is output, as it is output
148
+ # to the block.
149
+ #
150
+ # returns the status of the command, as one of the following symbols:
151
+ # :success, :failure, :not_found, :error
152
+ #
153
+ def run_command(*args)
154
+
155
+ # run the command
156
+ cmdstr = command_string(*args)
157
+ self.command_output = ""
158
+ before = Time.now
159
+ IO.popen(cmdstr + ' 2>&1', 'r') do |pipe|
160
+ while line = pipe.gets
161
+ if block_given?
162
+ yield(line)
163
+ end
164
+ self.command_output << line << "\n"
165
+ end
166
+ end
167
+ took = Time.now - before
168
+
169
+ # set the status
170
+ status = case $?.exitstatus
171
+ when 0 then :success
172
+ when 1 then :failure
173
+ when 127 then :not_found
174
+ else :error
175
+ end
176
+ if status == :success
177
+ log_command cmdstr
178
+ debug "took #{took} seconds."
179
+ debug command_output
180
+ else
181
+ msg = ' exited with "%s"' % $?.exitstatus
182
+ error cmdstr
183
+ error msg
184
+ error command_output if command_output.present?
185
+ yield(msg) if block_given?
186
+ end
187
+
188
+ # restore the original output_file name
189
+ unless restore_temporary_outfile
190
+ msg = 'could not restore temporary outfile'
191
+ error msg
192
+ yield(msg) if block_given?
193
+ status = :failure
194
+ end
195
+ return status
196
+ end
197
+
198
+ #def self.command_available?(command)
199
+ # command.present? and File.file?(command) and File.executable?(command)
200
+ #end
201
+
202
+ def command_available?(command)
203
+ command and
204
+ File.file?(command) and
205
+ File.executable?(command)
206
+ end
207
+
208
+ ##
209
+ ## PROTECTED
210
+ ##
211
+
212
+ protected
213
+
214
+ def log_command(command)
215
+ debug "COMMAND " + command
216
+ end
217
+
218
+ #
219
+ # returns a filename with the same base but a new extension
220
+ #
221
+ def replace_extension(filename, new_extension)
222
+ old_extension = (File.extname(filename) || '').to_s
223
+ new_extension = new_extension.to_s
224
+ if !old_extension.empty?
225
+ base = File.basename(filename, old_extension)
226
+ else
227
+ base = filename
228
+ end
229
+ if new_extension !~ /^\./
230
+ new_extension = "." + new_extension
231
+ end
232
+ if base =~ /\.$/
233
+ new_extension = new_extension.chomp
234
+ end
235
+ "#{base}#{new_extension}"
236
+ end
237
+
238
+ def extension(mime_type)
239
+ Media::MimeType.extension_from_mime_type(mime_type)
240
+ end
241
+
242
+ #
243
+ # usage:
244
+ #
245
+ # replace_file :from => filea, :to => fileb
246
+ #
247
+ def replace_file(args={})
248
+ from = args[:from].to_s
249
+ to = args[:to].to_s
250
+ raise ArgumentError if from.empty? || to.empty?
251
+ if File.exist?(from)
252
+ if File.exist?(to)
253
+ FileUtils.rm(to)
254
+ end
255
+ FileUtils.mv(from, to)
256
+ end
257
+ end
258
+
259
+ ##
260
+ ## PRIVATE
261
+ ##
262
+
263
+ private
264
+
265
+ def command_string(*args)
266
+ args.collect {|arg| shell_escape(arg.to_s)}.join(' ')
267
+ end
268
+
269
+ def shell_escape(str)
270
+ if str.empty?
271
+ "''"
272
+ elsif str =~ %r{\A[0-9A-Za-z+_-]+\z}
273
+ str
274
+ else
275
+ result = ''
276
+ str.scan(/('+)|[^']+/) do
277
+ if $1
278
+ result << %q{\'} * $1.length
279
+ else
280
+ result << %Q{'#{$&}'}
281
+ end
282
+ end
283
+ result
284
+ end
285
+ end
286
+
287
+ #
288
+ # returns true if the file as a suffix that matches the mime_type
289
+ #
290
+ def compatible_extension?(file, type)
291
+ file = file.to_s
292
+ ext = Media::MimeType.extension_from_mime_type(type)
293
+ if ext.nil?
294
+ return true
295
+ # ^^ if there is no defined extension for this type, then
296
+ # whatever the file has is fine
297
+ else
298
+ file_ext_type = Media::MimeType.mime_type_from_extension(file)
299
+ return Media::MimeType.compatible_types?(type, file_ext_type)
300
+ end
301
+ end
302
+
303
+ #
304
+ # ensure that the output_file has the correct suffix
305
+ # by setting a temporary one if the current one is not good.
306
+ #
307
+ def set_temporary_outfile
308
+ @temporary_outfile = false
309
+ if !compatible_extension?(output_file, output_type)
310
+ @temporary_outfile = true
311
+ @outfile_to_return = output_file
312
+ self.output_file = Media::TempFile.new(nil, output_type)
313
+ end
314
+ end
315
+
316
+ #
317
+ # moves the current output_file to match the filename we are
318
+ # supposed to return (which is stored in @outfile_to_return
319
+ # by set_temporary_outfile)
320
+ #
321
+ def restore_temporary_outfile
322
+ if @temporary_outfile
323
+ debug "moving output from #{output_file} to #{@outfile_to_return}"
324
+ replace_file from: output_file, to: @outfile_to_return
325
+ self.output_file = @outfile_to_return
326
+ end
327
+ return true
328
+ end
329
+
330
+ end
331
+ end
@@ -0,0 +1,154 @@
1
+ module Media
2
+ #
3
+ # transform image formats using the graphicsmagick command line executable "gm"
4
+ # requires "apt-get install graphicsmagick"
5
+ #
6
+
7
+ unless defined?(GRAPHICSMAGICK_COMMAND)
8
+ GRAPHICSMAGICK_COMMAND = `which gm`.chomp
9
+ end
10
+
11
+ #
12
+ # this is a little bit brittle, but I am not sure how else to do it.
13
+ #
14
+ unless defined?(GRAPHICSMAGICK_VERSION)
15
+ version = `#{GRAPHICSMAGICK_COMMAND} -version | head -1`.strip.sub(/GraphicsMagick ([0-9]+\.[0-9]+\.[0-9]+).*/,'\1').split('.')
16
+ GRAPHICSMAGICK_VERSION = [version[0].to_i, version[1].to_i, version[2].to_i]
17
+ end
18
+
19
+ class GraphicsMagickTransmogrifier < Media::Transmogrifier
20
+
21
+ def input_types
22
+ %w( application/pdf application/bzpdf application/gzpdf
23
+ application/postscript application/xpdf image/jpeg image/pjpeg image/gif
24
+ image/png image/x-png image/jpg image/tiff )
25
+ end
26
+
27
+ #def input_types
28
+ # self.class.input_types
29
+ #end
30
+
31
+ def output_types
32
+ %w( application/pdf image/jpeg image/pjpeg
33
+ image/gif image/png image/jpg image/tiff )
34
+ end
35
+
36
+ def available?
37
+ command_available?(GRAPHICSMAGICK_COMMAND)
38
+ end
39
+
40
+ #
41
+ # gm has an option -monitor that will spit out the progress.
42
+ # this could be interesting. we would need to use getc instead of gets
43
+ # on the pipe, since the progress is updated on a single line.
44
+ #
45
+ def run(&block)
46
+ # try converting first page only
47
+ status = convert(input_file.to_s + '[0]', &block)
48
+ # retry with full file if result was empty
49
+ if File.size(output_file.to_s) == 0
50
+ # reset filenames to the state before run
51
+ set_temporary_outfile
52
+ status = convert(&block)
53
+ end
54
+ FileUtils.chmod 0644, output_file.to_s if File.exist? output_file.to_s
55
+ return status
56
+ end
57
+
58
+ def convert(input = input_file.to_s, &block)
59
+ # +profile '*' will remove all the image profiles, which will save
60
+ # space (sometimes) and are not useful for thumbnails
61
+ arguments = [gm_command, 'convert', '+profile', "*"]
62
+ if options[:size]
63
+ # handle multiple size options, if it is an array.
64
+ sizes = options[:size].is_a?(Array) ? options[:size] : [options[:size]]
65
+ sizes.each do |size|
66
+ if version_less_than?(1,3,6)
67
+ size = size.sub('^','!')
68
+ end
69
+ arguments << '-geometry' << size
70
+ end
71
+ end
72
+ if options[:background]
73
+ # http://superuser.com/questions/213336/using-graphicsmagick-or-imagemagick-how-do-i-replace-transparency-with-a-fill-c
74
+ arguments << '-background' << options[:background] << '-extent' << '0x0'
75
+ end
76
+ if options[:crop]
77
+ # we add '+0+0' because we don't want tiles, just a single image
78
+ arguments << '-crop' << options[:crop]+'+0+0'
79
+ end
80
+ arguments << input << output_file
81
+ run_command(*arguments, &block)
82
+ end
83
+
84
+ # try to detect the dimensions of the first page.
85
+ # fallback to detecting dimensions of all pages.
86
+ def dimensions(filename)
87
+ run_dimensions(filename.to_s + '[0]') ||
88
+ run_dimensions(filename.to_s)
89
+ end
90
+
91
+ def run_dimensions(filename)
92
+ if available?
93
+ args = [gm_command, 'identify', '-format', '%m %w %h', filename]
94
+ dimensions = nil
95
+ status = run_command(*args) do |output|
96
+ dimensions = output
97
+ end
98
+ if status == :success
99
+ type, width, height = dimensions.split /\s/
100
+ return [width,height]
101
+ else
102
+ return nil
103
+ end
104
+ end
105
+ end
106
+
107
+ #
108
+ # returns the average color of an image, as represented by an array of red, green, blue values, integers
109
+ # in the range 0..255
110
+ #
111
+ # note: it is important that the geometry is "1x1!" ... without the ! this function might die a fiery death.
112
+ #
113
+ def average_color(filename)
114
+ if available?
115
+ args = [gm_command, 'convert', '-resize', '1x1!', filename, 'text:-']
116
+ color = nil
117
+ status = run_command(*args) do |output|
118
+ color = output
119
+ end
120
+ if status == :success
121
+ match = color.match(/^0,0: \(\s*(?<red>\d+),\s*(?<green>\d+),\s*(?<blue>\d+)\)/)
122
+ if match
123
+ return [match['red'].to_i, match['green'].to_i, match['blue'].to_i]
124
+ end
125
+ end
126
+ end
127
+ #if something goes wrong, assume white:
128
+ return [256,256,256]
129
+ end
130
+
131
+ # this override is just used for test, at the moment.
132
+ def gm_command
133
+ GRAPHICSMAGICK_COMMAND
134
+ end
135
+
136
+ def version_less_than?(major,minor,tiny)
137
+ installed_major, installed_minor, installed_tiny = GRAPHICSMAGICK_VERSION
138
+ if installed_major < major
139
+ true
140
+ elsif (installed_major == major)
141
+ if (installed_minor < minor)
142
+ true
143
+ elsif (installed_minor == minor) && (installed_tiny < tiny)
144
+ true
145
+ else
146
+ false
147
+ end
148
+ else
149
+ false
150
+ end
151
+ end
152
+
153
+ end
154
+ end