crabgrass_media 0.0.1

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