attachment_saver 1.0.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 (41) hide show
  1. data/.gitignore +3 -0
  2. data/MIT-LICENSE +19 -0
  3. data/README +137 -0
  4. data/Rakefile +16 -0
  5. data/attachment_saver.gemspec +41 -0
  6. data/init.rb +1 -0
  7. data/lib/attachment_saver.rb +171 -0
  8. data/lib/attachment_saver/version.rb +3 -0
  9. data/lib/attachment_saver_errors.rb +3 -0
  10. data/lib/datastores/file_system.rb +189 -0
  11. data/lib/datastores/in_column.rb +49 -0
  12. data/lib/misc/extended_tempfile.rb +12 -0
  13. data/lib/misc/file_size.rb +5 -0
  14. data/lib/misc/image_science_extensions.rb +102 -0
  15. data/lib/misc/mini_magick_extensions.rb +89 -0
  16. data/lib/processors/image.rb +187 -0
  17. data/lib/processors/image_science.rb +94 -0
  18. data/lib/processors/mini_magick.rb +103 -0
  19. data/lib/processors/r_magick.rb +120 -0
  20. data/test/attachment_saver_test.rb +162 -0
  21. data/test/database.yml +3 -0
  22. data/test/file_system_datastore_test.rb +468 -0
  23. data/test/fixtures/broken.jpg +1 -0
  24. data/test/fixtures/emptyextension. +0 -0
  25. data/test/fixtures/noextension +0 -0
  26. data/test/fixtures/test.jpg +0 -0
  27. data/test/fixtures/test.js +1 -0
  28. data/test/fixtures/wrongextension.png +0 -0
  29. data/test/image_fixtures.rb +69 -0
  30. data/test/image_operations.rb +114 -0
  31. data/test/image_processor_test.rb +67 -0
  32. data/test/image_processor_test_common.rb +81 -0
  33. data/test/image_science_processor_test.rb +20 -0
  34. data/test/in_column_datastore_test.rb +115 -0
  35. data/test/mini_magick_processor_test.rb +20 -0
  36. data/test/model_test.rb +205 -0
  37. data/test/public/.empty +0 -0
  38. data/test/rmagick_processor_test.rb +20 -0
  39. data/test/schema.rb +41 -0
  40. data/test/test_helper.rb +49 -0
  41. metadata +223 -0
@@ -0,0 +1,94 @@
1
+ require 'image_science'
2
+ require 'misc/image_science_extensions'
3
+ require 'misc/extended_tempfile'
4
+ require 'processors/image'
5
+
6
+ class ImageScienceProcessorError < ImageProcessorError; end
7
+
8
+ module AttachmentSaver
9
+ module Processors
10
+ module ImageScience
11
+ include Image
12
+
13
+ def with_image(filename, &block)
14
+ ::ImageScience.with_image(filename) {|image| block.call(image.extend(Operations))}
15
+ end
16
+
17
+ def with_image_attributes(filename, &block)
18
+ return with_image(filename, &block) unless ::ImageScience.respond_to?(:with_image_attributes)
19
+ ::ImageScience.with_image_attributes(filename) {|image| block.call(image)}
20
+ end
21
+
22
+ def examine_attachment
23
+ with_image_attributes(uploaded_file_path) do |original_image|
24
+ self.width = original_image.width if respond_to?(:width)
25
+ self.height = original_image.height if respond_to?(:height)
26
+ self.content_type = original_image.mime_type unless self.class.attachment_options[:keep_content_type] || original_image.mime_type.nil?
27
+ self.file_extension = original_image.file_type_extension unless self.class.attachment_options[:keep_file_extension] || original_image.file_type_extension.nil?
28
+ end
29
+ rescue AttachmentSaverError
30
+ raise
31
+ rescue Exception => ex
32
+ raise ImageScienceProcessorError, "#{ex.class}: #{ex.message}", ex.backtrace
33
+ end
34
+
35
+ def process_image(original_image, derived_format_name, resize_format)
36
+ resize_format = Image.from_geometry_string(resize_format) if resize_format.is_a?(String)
37
+
38
+ original_image.send(*resize_format) do |derived_image|
39
+ return nil unless want_format?(derived_format_name, derived_image.width, derived_image.height)
40
+
41
+ if derived_image.file_type == 'GIF' # && derived_image.depth != 8 # TODO: submit patch to add depth attribute
42
+ # as a special case hack, don't try and save 24-bit derived images into 8-bit-only GIF format
43
+ # (ImageScience doesn't resample back down, so it throws errors if we try to do that)
44
+ derived_content_type = 'image/png'
45
+ derived_extension = 'png'
46
+ else
47
+ # both original_filename and content_type must be defined for parents when using image processing
48
+ # - but apps can just define them using attr_accessor if they don't want them persisted to db
49
+ derived_content_type = derived_image.mime_type || original_image.mime_type || content_type # note that mime_type will return nil instead of returning any of the freeimage-invented content types
50
+ derived_extension = (derived_image.file_type || file_extension).downcase # in fact, derived_image.file_type should always work; the only situation in which it could return nil is if freeimage is extended to support a new image format but image_science_extensions isn't updated
51
+ end
52
+
53
+ # we leverage tempfiles as discussed in the uploaded_file method
54
+ temp = ExtendedTempfile.new("asitemp", tempfile_directory, derived_extension)
55
+ temp.binmode
56
+ temp.close
57
+ derived_image.save(temp.path)
58
+ temp.open # we close & reopen so we see the file the processor wrote to, even if it created a new file rather than writing into our tempfile
59
+
60
+ { :format_name => derived_format_name.to_s,
61
+ :width => derived_image.width,
62
+ :height => derived_image.height,
63
+ :content_type => derived_content_type,
64
+ :file_extension => derived_extension,
65
+ :uploaded_data => temp }
66
+ end
67
+ end
68
+
69
+ module Operations
70
+ include AttachmentSaver::Processors::Image::Operations
71
+
72
+ def file_type_extension
73
+ file_type.downcase
74
+ end
75
+
76
+ def resize_to(new_width, new_height, &block)
77
+ resize(new_width, new_height) do |image|
78
+ image.extend Operations
79
+ block.call(image) # ImageScience itself doesn't accept a block argument (it yields only)
80
+ end
81
+ end
82
+
83
+ def crop_to(new_width, new_height, &block) # crops to the center
84
+ left = (width - new_width)/2
85
+ right = (height - new_height)/2
86
+ with_crop(left, right, left + new_width, right + new_height) do |image|
87
+ image.extend Operations
88
+ block.call(image) # as for resize, with_crop doesn't take a block itself
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,103 @@
1
+ require 'mini_magick'
2
+ require 'misc/mini_magick_extensions'
3
+ require 'misc/extended_tempfile'
4
+ require 'processors/image'
5
+
6
+ class MiniMagickProcessorError < ImageProcessorError; end
7
+
8
+ module AttachmentSaver
9
+ module Processors
10
+ module MiniMagick
11
+ include Image
12
+
13
+ def with_image(filename, &block)
14
+ # note that we are instantiating minimagick on the file itself, not a copy (which is
15
+ # what gets produced if you call from_file); we don't do any mutating operations on
16
+ # our instances themselves (resize_to and crop_to create new instances).
17
+ if ::MiniMagick::Image.respond_to?(:open) # v3
18
+ image = ::MiniMagick::Image.open(filename)
19
+ else # v1
20
+ image = ::MiniMagick::Image.new(filename)
21
+ end
22
+ block.call(image.extend(Operations))
23
+ end
24
+
25
+ def with_image_attributes(filename, &block)
26
+ # MiniMagick doesn't actually load the image, it just keeps a reference to the filename
27
+ # and invokes the imagemagick programs to determine attributes
28
+ with_image(filename, &block)
29
+ end
30
+
31
+ def examine_attachment
32
+ with_image_attributes(uploaded_file_path) do |original_image|
33
+ self.content_type = original_image.mime_type unless self.class.attachment_options[:keep_content_type] || original_image.mime_type.blank?
34
+ self.file_extension = original_image.file_type_extension unless self.class.attachment_options[:keep_file_extension] || original_image.file_type_extension.blank?
35
+ self.width = original_image.width if respond_to?(:width)
36
+ self.height = original_image.height if respond_to?(:height)
37
+ end
38
+ rescue AttachmentSaverError
39
+ raise
40
+ rescue Exception => ex
41
+ raise MiniMagickProcessorError, "#{ex.class}: #{ex.message}", ex.backtrace
42
+ end
43
+
44
+ def process_image(original_image, derived_format_name, resize_format)
45
+ resize_format = Image.from_geometry_string(resize_format) if resize_format.is_a?(String)
46
+
47
+ original_image.send(*resize_format) do |derived_image|
48
+ return nil unless want_format?(derived_format_name, derived_image.width, derived_image.height)
49
+
50
+ # both original_filename and content_type must be defined for parents when using image processing
51
+ # - but apps can just define them using attr_accessor if they don't want them persisted to db
52
+ derived_content_type = derived_image.mime_type || original_image.mime_type || content_type
53
+ derived_extension = derived_image.file_type_extension
54
+
55
+ # we leverage tempfiles as discussed in the uploaded_file method
56
+ temp = ExtendedTempfile.new("asmtemp", tempfile_directory, derived_extension)
57
+ temp.binmode
58
+ temp.close
59
+ derived_image.write(temp.path)
60
+ temp.open # we close & reopen so we see the file the processor wrote to, even if it created a new file rather than writing into our tempfile
61
+
62
+ { :format_name => derived_format_name.to_s,
63
+ :width => derived_image.width,
64
+ :height => derived_image.height,
65
+ :content_type => derived_content_type,
66
+ :file_extension => derived_extension,
67
+ :uploaded_data => temp }
68
+ end
69
+ end
70
+
71
+ module Operations
72
+ include AttachmentSaver::Processors::Image::Operations
73
+
74
+ def file_type_extension
75
+ case format.downcase
76
+ when 'jpeg' then 'jpg'
77
+ else format.downcase
78
+ end
79
+ end
80
+
81
+ def format; @__format ||= self[:format]; end # cached as each call to [] results in a process execution!
82
+ def width; @__width ||= self[:width]; end # note that we can cache as we don't ever modify instances -
83
+ def height; @__height ||= self[:height]; end # whereas MiniMagick may, in general.
84
+
85
+ def resize_to(new_width, new_height, &block)
86
+ image = dup
87
+ image.resize("#{new_width}x#{new_height}!")
88
+ image.extend Operations
89
+ block.call(image)
90
+ end
91
+
92
+ def crop_to(new_width, new_height, &block) # crops to the center
93
+ left = (width - new_width)/2
94
+ right = (height - new_height)/2
95
+ image = dup
96
+ image << "-crop #{new_width}x#{new_height}+#{left}+#{right} +repage" # mini_magick's #crop doesn't support the repage flag
97
+ image.extend Operations
98
+ block.call(image)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,120 @@
1
+ begin
2
+ require 'RMagick'
3
+ rescue LoadError
4
+ require 'rmagick'
5
+ end
6
+ require 'misc/extended_tempfile'
7
+ require 'processors/image'
8
+
9
+ class RMagickProcessorError < ImageProcessorError; end
10
+
11
+ module AttachmentSaver
12
+ module Processors
13
+ module RMagick
14
+ include Image
15
+
16
+ def with_image(filename, &block)
17
+ image = Magick::Image.read(filename).first
18
+ block.call(image.extend(Operations))
19
+ end
20
+
21
+ def with_image_attributes(filename, &block)
22
+ image = Magick::Image.ping(filename).first
23
+ block.call(image.extend(Operations))
24
+ end
25
+
26
+ def examine_attachment
27
+ with_image_attributes(uploaded_file_path) do |original_image|
28
+ self.width = original_image.width if respond_to?(:width)
29
+ self.height = original_image.height if respond_to?(:height)
30
+ self.content_type = original_image.corrected_mime_type unless self.class.attachment_options[:keep_content_type] || original_image.corrected_mime_type.nil?
31
+ self.file_extension = original_image.file_type_extension unless self.class.attachment_options[:keep_file_extension] || original_image.file_type_extension.nil?
32
+ end
33
+ rescue AttachmentSaverError
34
+ raise
35
+ rescue Exception => ex
36
+ raise RMagickProcessorError, "#{ex.class}: #{ex.message}", ex.backtrace
37
+ end
38
+
39
+ def process_image(original_image, derived_format_name, resize_format)
40
+ resize_format = Image.from_geometry_string(resize_format) if resize_format.is_a?(String)
41
+
42
+ result = original_image.send(*resize_format) do |derived_image|
43
+ return nil unless want_format?(derived_format_name, derived_image.width, derived_image.height)
44
+
45
+ # both original_filename and content_type must be defined for parents when using image processing
46
+ # - but apps can just define them using attr_accessor if they don't want them persisted to db
47
+ derived_content_type = derived_image.corrected_mime_type || original_image.corrected_mime_type || content_type
48
+ derived_extension = derived_image.file_type_extension
49
+
50
+ # we leverage tempfiles as discussed in the uploaded_file method
51
+ temp = ExtendedTempfile.new("asrtemp", tempfile_directory, derived_extension)
52
+ temp.binmode
53
+ temp.close
54
+ derived_image.write(temp.path)
55
+ temp.open # we close & reopen so we see the file the processor wrote to, even if it created a new file rather than writing into our tempfile
56
+
57
+ { :format_name => derived_format_name.to_s,
58
+ :width => derived_image.width,
59
+ :height => derived_image.height,
60
+ :content_type => derived_content_type,
61
+ :file_extension => derived_extension,
62
+ :uploaded_data => temp }
63
+ end
64
+
65
+ # modern versions of RMagick don't leak memory. however, the (many and large) internal
66
+ # buffers malloced inside the ImageMagick library are not allocated via the Ruby memory
67
+ # management functions. as Ruby GC runs are normally triggered at the point when those Ruby
68
+ # memory management functions request a larger heap, ImageMagick's extra allocations will
69
+ # not trigger a GC run. so while no memory has been leaked - all the allocations by the
70
+ # ImageMagick library *will* get freed when GC runs - GC will typically not run even if you
71
+ # process a series of images and end up using all of the memory that can be made available
72
+ # to the process, at which point your process dies! until such time as RMagick rewraps the
73
+ # ImageMagick memory allocation functions to put them through Ruby's (as was done in the
74
+ # as-yet-uncompleted MagickWand project), we force a GC after each image processing to
75
+ # ensure that your processes stay happy.
76
+ GC.start
77
+ result
78
+ end
79
+
80
+ module Operations
81
+ include AttachmentSaver::Processors::Image::Operations
82
+
83
+ def corrected_mime_type
84
+ case mime_type
85
+ when 'image/x-jpeg' then 'image/jpeg'
86
+ when 'image/x-magick' then nil
87
+ else mime_type
88
+ end
89
+ end
90
+
91
+ def file_type_extension
92
+ case format.downcase
93
+ when 'jpeg' then 'jpg'
94
+ else format.downcase
95
+ end
96
+ end
97
+
98
+ def width
99
+ columns
100
+ end
101
+
102
+ def height
103
+ rows
104
+ end
105
+
106
+ def resize_to(new_width, new_height, &block)
107
+ image = resize(new_width, new_height)
108
+ image.extend Operations
109
+ block.call(image)
110
+ end
111
+
112
+ def crop_to(new_width, new_height, &block) # crops to the center
113
+ image = crop(Magick::CenterGravity, new_width, new_height, true)
114
+ image.extend Operations
115
+ block.call(image)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,162 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper'))
2
+ require 'attachment_saver'
3
+
4
+ class AttachmentSaverTest < Test::Unit::TestCase
5
+ def test_split_filename
6
+ assert_equal ['a', nil], AttachmentSaver::split_filename('a')
7
+ assert_equal ['a', ''], AttachmentSaver::split_filename('a.')
8
+ assert_equal ['a', 'b'], AttachmentSaver::split_filename('a.b')
9
+ assert_equal ['a', 'bcde'], AttachmentSaver::split_filename('a.bcde')
10
+ assert_equal ['a.bcde', 'fgh'], AttachmentSaver::split_filename('a.bcde.fgh')
11
+ end
12
+
13
+ class SomeModel
14
+ include AttachmentSaver::InstanceMethods
15
+ class_attribute :attachment_options
16
+
17
+ attr_accessor :size, :content_type, :original_filename
18
+ end
19
+
20
+ module TempfileAttributes
21
+ def original_filename
22
+ "test.txt"
23
+ end
24
+
25
+ def content_type
26
+ "text/plain"
27
+ end
28
+ end
29
+
30
+ module ExtensionlessAttributes
31
+ def original_filename
32
+ "test"
33
+ end
34
+
35
+ def content_type
36
+ "text/plain"
37
+ end
38
+ end
39
+
40
+ module OriginalFilenameHasPathAttributes
41
+ def original_filename
42
+ " c:\\test/foo.txt "
43
+ end
44
+
45
+ def content_type
46
+ "text/plain"
47
+ end
48
+ end
49
+
50
+ def contents_of(file)
51
+ file.rewind
52
+ file.read
53
+ end
54
+
55
+ def test_default_methods
56
+ model = SomeModel.new
57
+ assert_equal nil, model.uploaded_data
58
+ assert_equal nil, model.uploaded_file
59
+ assert_equal false, model.process_attachment?
60
+ assert File.directory?(model.tempfile_directory)
61
+ end
62
+
63
+ def test_uploaded_data_setters_and_extensions
64
+ SomeModel.attachment_options = {}
65
+
66
+ model = SomeModel.new
67
+ model.uploaded_data = 'test #1'
68
+ assert_equal 7, model.size
69
+ assert_equal nil, model.content_type
70
+ assert_equal nil, model.original_filename
71
+ assert_equal 'test #1', model.uploaded_data # before converting to an uploaded_file
72
+ assert_not_equal nil, model.uploaded_file
73
+ assert model.uploaded_file.is_a?(Tempfile)
74
+ assert_equal model.uploaded_file.object_id, model.uploaded_file.object_id, 'uploaded_file should return the same instance each time'
75
+ assert_equal 'test #1', model.uploaded_data # after converting to an uploaded_file
76
+ assert_equal 'test #1', contents_of(model.uploaded_file)
77
+ assert_equal 'test #1', model.uploaded_data
78
+ assert_equal 'bin', model.file_extension
79
+ model.file_extension = 'ext'
80
+ assert_equal 'ext', model.file_extension
81
+
82
+ model = SomeModel.new
83
+ model.uploaded_data = StringIO.new('test #2')
84
+ assert_equal 7, model.size
85
+ assert_equal nil, model.content_type
86
+ assert_equal nil, model.original_filename
87
+ assert_not_equal nil, model.uploaded_file
88
+ assert model.uploaded_file.is_a?(Tempfile)
89
+ assert_equal model.uploaded_file.object_id, model.uploaded_file.object_id, 'uploaded_file should return the same instance each time'
90
+ assert_equal 'test #2', model.uploaded_data
91
+ assert_equal 'test #2', contents_of(model.uploaded_file)
92
+ assert_equal 'test #2', model.uploaded_data
93
+ assert_equal 'bin', model.file_extension
94
+ model.file_extension = 'ext'
95
+ assert_equal 'ext', model.file_extension
96
+
97
+ Tempfile.open('test') do |tempfile|
98
+ tempfile.write('test #3')
99
+ model = SomeModel.new
100
+ model.uploaded_data = tempfile
101
+ assert_equal 7, model.size
102
+ assert_equal nil, model.content_type
103
+ assert_equal nil, model.original_filename
104
+ assert_equal tempfile.object_id, model.uploaded_file.object_id, 'uploaded_file should return the originally given tempfile'
105
+ assert_equal 'test #3', model.uploaded_data
106
+ assert_equal 'test #3', contents_of(model.uploaded_file)
107
+ assert_equal 'test #3', model.uploaded_data
108
+ assert_equal 'bin', model.file_extension
109
+ model.file_extension = 'ext'
110
+ assert_equal 'ext', model.file_extension
111
+ end
112
+
113
+ Tempfile.open('test') do |tempfile|
114
+ tempfile.extend TempfileAttributes
115
+ tempfile.write('test #4')
116
+ model = SomeModel.new
117
+ model.uploaded_data = tempfile
118
+ assert_equal 7, model.size
119
+ assert_equal "text/plain", model.content_type
120
+ assert_equal "test.txt", model.original_filename
121
+ assert_equal tempfile.object_id, model.uploaded_file.object_id, 'uploaded_file should return the originally given tempfile'
122
+ assert_equal 'test #4', model.uploaded_data
123
+ assert_equal 'test #4', contents_of(model.uploaded_file)
124
+ assert_equal 'test #4', model.uploaded_data
125
+ assert_equal 'txt', model.file_extension
126
+ model.file_extension = 'ext'
127
+ assert_equal 'ext', model.file_extension
128
+ end
129
+
130
+ Tempfile.open('test') do |tempfile|
131
+ tempfile.extend ExtensionlessAttributes
132
+ model = SomeModel.new
133
+ model.uploaded_data = tempfile
134
+ assert_equal "test", model.original_filename
135
+ assert_equal 'bin', model.file_extension
136
+ model.file_extension = 'ext'
137
+ assert_equal 'ext', model.file_extension
138
+ end
139
+
140
+ Tempfile.open('test') do |tempfile|
141
+ tempfile.extend OriginalFilenameHasPathAttributes
142
+
143
+ model = SomeModel.new
144
+ model.uploaded_data = tempfile
145
+ assert_equal "foo.txt", model.original_filename
146
+ assert_equal 'txt', model.file_extension
147
+
148
+ SomeModel.attachment_options = {:keep_original_filename_path => true}
149
+ model = SomeModel.new
150
+ model.uploaded_data = tempfile
151
+ assert_equal "c:\\test/foo.txt", model.original_filename
152
+ assert_equal 'txt', model.file_extension
153
+ end
154
+
155
+ model = SomeModel.new
156
+ model.uploaded_data = '' # this is what controllers get sent when there's a file field but no file selected; attachment_saver accordingly handles blank strings as a special case
157
+ assert_equal nil, model.uploaded_file
158
+ assert_equal nil, model.size
159
+ assert_equal nil, model.content_type
160
+ assert_equal nil, model.original_filename
161
+ end
162
+ end