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.
- checksums.yaml +7 -0
- data/README.md +29 -0
- data/Rakefile +34 -0
- data/config/initializers/paths.rb +9 -0
- data/lib/media.rb +64 -0
- data/lib/media/mime_type.rb +272 -0
- data/lib/media/temp_file.rb +152 -0
- data/lib/media/transmogrifier.rb +331 -0
- data/lib/media/transmogrifiers/graphicsmagick.rb +154 -0
- data/lib/media/transmogrifiers/inkscape.rb +58 -0
- data/lib/media/transmogrifiers/libremagick.rb +53 -0
- data/lib/media/transmogrifiers/libreoffice.rb +155 -0
- data/lib/media/version.rb +3 -0
- data/lib/tasks/media_tasks.rake +4 -0
- data/test/dummy/db/test.sqlite3 +0 -0
- data/test/dummy/log/test.log +1046 -0
- data/test/files/anarchism.svg +361 -0
- data/test/files/corrupt.jpg +0 -0
- data/test/files/lyra.png +0 -0
- data/test/files/msword.doc +0 -0
- data/test/sleepy_transmogrifier.rb +39 -0
- data/test/support/file.rb +21 -0
- data/test/tempfile_test.rb +8 -0
- data/test/test_helper.rb +13 -0
- data/test/transmogrifier_test.rb +106 -0
- metadata +126 -0
@@ -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
|