attachment_saver 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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,49 @@
1
+ require 'attachment_saver_errors'
2
+
3
+ class InColumnAttachmentDatastoreError < AttachmentDataStoreError; end
4
+
5
+ module AttachmentSaver
6
+ module DataStores
7
+ module InColumn
8
+ def self.included(base)
9
+ base.attachment_options[:column_name] ||= 'data'
10
+ base.attachment_options[:temp_directory] ||= Dir.tmpdir
11
+ end
12
+
13
+ def save_attachment
14
+ return unless @save_upload # this method is called every time the model is saved, not just when a new file has been uploaded
15
+
16
+ send("#{self.class.attachment_options[:column_name]}=", uploaded_data)
17
+
18
+ save_temporary_and_process_attachment if process_attachment?
19
+
20
+ @save_upload = nil
21
+ end
22
+
23
+ def tidy_attachment; end
24
+ def delete_attachment; end # delete_attachment is used when the record is deleted, so we don't need to do anything
25
+
26
+ def in_storage?
27
+ !send(self.class.attachment_options[:column_name]).nil?
28
+ end
29
+
30
+ # there is no public_path, since you need to make a controller to pull the blob from the database
31
+
32
+ def reprocess!
33
+ raise "this attachment already has a file open to process" unless uploaded_file.nil?
34
+ save_temporary_and_process_attachment
35
+ save!
36
+ end
37
+
38
+ def save_temporary_and_process_attachment
39
+ FileUtils.mkdir_p(self.class.attachment_options[:temp_directory])
40
+ Tempfile.open("asctemp", self.class.attachment_options[:temp_directory]) do |temp|
41
+ temp.binmode
42
+ temp.write(send(self.class.attachment_options[:column_name]))
43
+ temp.flush
44
+ process_attachment_with_wrapping(temp.path)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,12 @@
1
+ require 'tempfile'
2
+
3
+ class ExtendedTempfile < Tempfile
4
+ def initialize(basename, tmpdir = Dir.tmpdir, extension = '')
5
+ @extension = extension
6
+ super(basename, tmpdir)
7
+ end
8
+
9
+ def make_tmpname(basename, n)
10
+ sprintf('%s.%d.%d.%s', basename, $$, n || 0, @extension)
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ class File
2
+ def size # for compatibility with StringIO & Tempfile
3
+ stat.size
4
+ end
5
+ end
@@ -0,0 +1,102 @@
1
+ require 'image_science'
2
+
3
+ class ImageScience
4
+ # TODO: submit a patch to image_science calling the built-in freeimage functions instead of using these lists
5
+
6
+ # from freeimage.h
7
+ FIF_UNKNOWN = -1;
8
+ FIF_BMP = 0;
9
+ FIF_ICO = 1;
10
+ FIF_JPEG = 2;
11
+ FIF_JNG = 3;
12
+ FIF_KOALA = 4;
13
+ FIF_LBM = 5;
14
+ FIF_IFF = FIF_LBM;
15
+ FIF_MNG = 6;
16
+ FIF_PBM = 7;
17
+ FIF_PBMRAW = 8;
18
+ FIF_PCD = 9;
19
+ FIF_PCX = 10;
20
+ FIF_PGM = 11;
21
+ FIF_PGMRAW = 12;
22
+ FIF_PNG = 13;
23
+ FIF_PPM = 14;
24
+ FIF_PPMRAW = 15;
25
+ FIF_RAS = 16;
26
+ FIF_TARGA = 17;
27
+ FIF_TIFF = 18;
28
+ FIF_WBMP = 19;
29
+ FIF_PSD = 20;
30
+ FIF_CUT = 21;
31
+ FIF_XBM = 22;
32
+ FIF_XPM = 23;
33
+ FIF_DDS = 24;
34
+ FIF_GIF = 25;
35
+ FIF_HDR = 26;
36
+ FIF_FAXG3 = 27;
37
+ FIF_SGI = 28;
38
+
39
+ FILE_TYPES = {
40
+ # from the documentation
41
+ FIF_BMP => 'BMP',
42
+ FIF_ICO => 'ICO',
43
+ FIF_JPEG => 'JPG',
44
+ FIF_JNG => 'JNG',
45
+ FIF_KOALA => 'KOA',
46
+ FIF_LBM => 'LBM',
47
+ FIF_MNG => 'MNG',
48
+ FIF_PBM => 'PBM',
49
+ FIF_PBMRAW => 'PBM',
50
+ FIF_PCD => 'PCD',
51
+ FIF_PCX => 'PCX',
52
+ FIF_PGM => 'PGM',
53
+ FIF_PGMRAW => 'PGM',
54
+ FIF_PNG => 'PNG',
55
+ FIF_PPM => 'PPM',
56
+ FIF_PPMRAW => 'PPM',
57
+ FIF_RAS => 'RAS',
58
+ FIF_TARGA => 'TGA',
59
+ FIF_TIFF => 'TIFF',
60
+ FIF_WBMP => 'WBMP',
61
+ FIF_PSD => 'PSD',
62
+ FIF_CUT => 'CUT',
63
+ FIF_XBM => 'XBM',
64
+ FIF_XPM => 'XPM',
65
+ FIF_DDS => 'DDS',
66
+ FIF_GIF => 'GIF',
67
+ FIF_HDR => 'HDR',
68
+ FIF_FAXG3 => 'G3',
69
+ FIF_SGI => 'SGI'
70
+ }
71
+
72
+ MIME_TYPES = {
73
+ # we only list the standardised MIME types here, leaving out any freeimage invented (ie. all the image/freeimage-* values)
74
+ FIF_BMP => 'image/bmp',
75
+ FIF_ICO => 'image/x-icon',
76
+ FIF_JPEG => 'image/jpeg',
77
+ FIF_MNG => 'video/x-mng',
78
+ FIF_PCD => 'image/x-photo-cd',
79
+ FIF_PCX => 'image/x-pcx',
80
+ FIF_PNG => 'image/png',
81
+ FIF_RAS => 'image/x-cmu-raster',
82
+ FIF_TIFF => 'image/tiff',
83
+ FIF_WBMP => 'image/vnd.wap.wbmp',
84
+ FIF_XBM => 'image/x-xbitmap',
85
+ FIF_XPM => 'image/xpm',
86
+ FIF_GIF => 'image/gif',
87
+ FIF_FAXG3 => 'image/fax-g3',
88
+ FIF_SGI => 'image/sgi'
89
+ }
90
+
91
+ # determines the file format of this image file (represented as an uppercase string).
92
+ # nil if the file type is not known, or if no image has been loaded.
93
+ def file_type
94
+ FILE_TYPES[@file_type]
95
+ end
96
+
97
+ # determines the MIME type of this image file.
98
+ # nil if the file type is not known, or if no image has been loaded.
99
+ def mime_type
100
+ MIME_TYPES[@file_type]
101
+ end
102
+ end
@@ -0,0 +1,89 @@
1
+ require 'mini_magick'
2
+
3
+ class MiniMagick::Image
4
+ MIME_TYPES = {
5
+ 'BMP' => 'image/bmp',
6
+ 'CUR' => 'image/x-win-bitmap',
7
+ 'DCX' => 'image/dcx',
8
+ 'EPDF' => 'application/pdf',
9
+ 'EPI' => 'application/postscript',
10
+ 'EPS' => 'application/postscript',
11
+ 'EPSF' => 'application/postscript',
12
+ 'EPSI' => 'application/postscript',
13
+ 'EPT' => 'application/postscript',
14
+ 'EPT2' => 'application/postscript',
15
+ 'EPT3' => 'application/postscript',
16
+ 'FAX' => 'image/g3fax',
17
+ 'FITS' => 'image/x-fits',
18
+ 'G3' => 'image/g3fax',
19
+ 'GIF' => 'image/gif',
20
+ 'GIF87' => 'image/gif',
21
+ 'ICB' => 'application/x-icb',
22
+ 'ICO' => 'image/x-win-bitmap',
23
+ 'ICON' => 'image/x-win-bitmap',
24
+ 'JNG' => 'image/jng',
25
+ 'JPEG' => 'image/jpeg',
26
+ 'JPG' => 'image/jpeg',
27
+ 'M2V' => 'video/mpeg2',
28
+ 'MIFF' => 'application/x-mif',
29
+ 'MNG' => 'video/mng',
30
+ 'MPEG' => 'video/mpeg',
31
+ 'MPG' => 'video/mpeg',
32
+ 'OTB' => 'image/x-otb',
33
+ 'PALM' => 'image/x-palm',
34
+ 'PBM' => 'image/pbm',
35
+ 'PCD' => 'image/pcd',
36
+ 'PCDS' => 'image/pcd',
37
+ 'PCL' => 'application/pcl',
38
+ 'PCT' => 'image/pict',
39
+ 'PCX' => 'image/x-pcx',
40
+ 'PDB' => 'application/vnd.palm',
41
+ 'PDF' => 'application/pdf',
42
+ 'PGM' => 'image/x-pgm',
43
+ 'PICON' => 'image/xpm',
44
+ 'PICT' => 'image/pict',
45
+ 'PJPEG' => 'image/pjpeg',
46
+ 'PNG' => 'image/png',
47
+ 'PNG24' => 'image/png',
48
+ 'PNG32' => 'image/png',
49
+ 'PNG8' => 'image/png',
50
+ 'PNM' => 'image/pbm',
51
+ 'PPM' => 'image/x-ppm',
52
+ 'PS' => 'application/postscript',
53
+ 'PSD' => 'image/x-photoshop',
54
+ 'PTIF' => 'image/x-ptiff',
55
+ 'RAS' => 'image/ras',
56
+ 'SGI' => 'image/sgi',
57
+ 'SUN' => 'image/ras',
58
+ 'SVG' => 'image/svg+xml',
59
+ 'SVGZ' => 'image/svg',
60
+ 'TEXT' => 'text/plain',
61
+ 'TGA' => 'image/tga',
62
+ 'TIF' => 'image/tiff',
63
+ 'TIFF' => 'image/tiff',
64
+ 'TXT' => 'text/plain',
65
+ 'VDA' => 'image/vda',
66
+ 'VIFF' => 'image/x-viff',
67
+ 'VST' => 'image/vst',
68
+ 'WBMP' => 'image/vnd.wap.wbmp',
69
+ 'XBM' => 'image/x-xbitmap',
70
+ 'XPM' => 'image/x-xbitmap',
71
+ 'XV' => 'image/x-viff',
72
+ 'XWD' => 'image/xwd',
73
+ }
74
+
75
+ # determines the MIME type of this image file.
76
+ # nil if the file type is not known, or if no image has been loaded.
77
+ def mime_type
78
+ MIME_TYPES[format]
79
+ end
80
+
81
+ # creates an independent copy of the file.
82
+ def dup
83
+ if ::MiniMagick::Image.respond_to?(:read) # v3
84
+ self.class.read(to_blob)
85
+ else # v1
86
+ self.class.from_blob(to_blob, File.extname(path))
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,187 @@
1
+ require 'attachment_saver_errors'
2
+
3
+ class ImageProcessorError < AttachmentProcessorError; end
4
+
5
+ module AttachmentSaver
6
+ module Processors
7
+ # shared code for all image processors
8
+ module Image
9
+ def image?
10
+ return false if content_type.blank?
11
+ parts = content_type.split(/\//)
12
+ parts.size == 2 && parts.first.strip == 'image'
13
+ end
14
+
15
+ def before_validate_attachment
16
+ examine_attachment unless uploaded_file.nil? || derived_image?
17
+ rescue ImageProcessorError
18
+ # we examine all files, regardless of whether the client browser labelled them an
19
+ # image, because they may be an image with the wrong extension or content type.
20
+ # but this will raise for non-image files, so ignore such errors but make sure
21
+ # image? will return false, if it doesn't already.
22
+ self.content_type = "application/octet-stream" if image?
23
+ end
24
+
25
+ def process_attachment?
26
+ image? && !self.class.attachment_options[:formats].blank? && !derived_image?
27
+ end
28
+
29
+ # determines if this is a derived image. used to prevent infinite recursion when
30
+ # storing the derived images in the same model as the originals (and as a secondary
31
+ # benefit avoid unnecessary work examining images for derived images, for which the
32
+ # full metadata is already filled in by the resizing code).
33
+ def derived_image?
34
+ respond_to?(:format_name) && !format_name.blank?
35
+ end
36
+
37
+ # determines if a particular configured derived image should be created. this
38
+ # implementation, which always returns true, may be overridden by applications to make
39
+ # certain formats conditional (for example, only creating certain larger sizes if the
40
+ # original image was at least that large).
41
+ def want_format?(derived_name, derived_width, derived_height)
42
+ true
43
+ end
44
+
45
+ def process_attachment(filename)
46
+ with_image(filename) do |original_image|
47
+ unless self.class.attachment_options[:formats].blank?
48
+ old_children = new_record? ? {} : formats.group_by(&:format_name)
49
+ self.class.attachment_options[:formats].each do |derived_name, resize_format|
50
+ derived_attributes = process_image(original_image, derived_name, resize_format)
51
+ if derived_attributes
52
+ if old_children[derived_name.to_s]
53
+ update_derived(old_children[derived_name.to_s].pop, derived_attributes)
54
+ else
55
+ build_derived(derived_attributes)
56
+ end
57
+ end
58
+ end
59
+ old_children = old_children.values.flatten
60
+ formats.destroy(old_children) unless old_children.blank? # remove any old derived images for formats for which want_format? now returned false
61
+ end
62
+ end
63
+ rescue AttachmentSaverError
64
+ raise
65
+ rescue Exception => ex
66
+ raise ImageProcessorError, "#{ex.class}: #{ex.message}", ex.backtrace
67
+ end
68
+
69
+ # builds a new derived image model instance, but doesn't save it.
70
+ # provided so apps can easily override this step.
71
+ def build_derived(attributes)
72
+ formats.build(attributes)
73
+ end
74
+
75
+ # updates an existing derived image model instance, and queues it for save when this
76
+ # model is saved. provided so apps can easily override this step.
77
+ def update_derived(derived, attributes)
78
+ derived.attributes = attributes
79
+ @updated_derived_children ||= []
80
+ @updated_derived_children << derived # we don't want to save it just yet in case processing subsequent images fail; rails will automatically save it if we're a new record, but we have to do it ourselves in an after_save if not
81
+ derived
82
+ end
83
+
84
+ # unpacks a resize geometry string into an array contining the corresponding image
85
+ # operation method name (see Operations below, plus any from your chosen image processor)
86
+ # followed by the arguments to that method.
87
+ def self.from_geometry_string(geom)
88
+ match, w, cross, h, flag = geom.match(/^(\d+\.?\d*)?(?:([xX])(\d+\.?\d*)?)?([!%<>#*])?$/).to_a
89
+ raise "couldn't parse geometry string '#{geom}'" if match.nil? || (w.nil? && h.nil?)
90
+ h = w unless cross # there's <w>x<h>, there's <w>x, there's x<h>, and then there's just plain <n>, which means <w>=<h>=<n>
91
+ return [:scale_by, (w || h).to_f/100, (h || w).to_f/100] if flag == '%'
92
+ operation = case flag
93
+ when nil then :scale_to_fit
94
+ when '!' then w && h ? :squish : :scale_to_fit
95
+ when '>' then :shrink_to_fit
96
+ when '<' then :expand_to_fit
97
+ when '*' then :scale_to_cover
98
+ when '#' then :cover_and_crop
99
+ end
100
+ [operation, w ? w.to_i : nil, h ? h.to_i : nil]
101
+ end
102
+
103
+ module Operations
104
+ # if they choose to use this module to implement the resize operations, the processor
105
+ # module just needs to implement width, height, resize_to(new_width, new_height, &block),
106
+ # and crop_to(new_width, new_height, &block)
107
+
108
+ # squishes the image to the given width and height, without preserving the aspect ratio.
109
+ # yields this image itself if it is already the given size.
110
+ def squish(new_width, new_height, &block)
111
+ return block.call(self) if new_width == width && new_height == height
112
+ resize_to(new_width.to_i, new_height.to_i, &block)
113
+ end
114
+
115
+ # scales the image by the given factors.
116
+ def scale_by(width_factor, height_factor, &block)
117
+ squish(width*width_factor, height*height_factor, &block)
118
+ end
119
+
120
+ # calculates the appropriate dimensions for scale_to_fit.
121
+ def scale_dimensions_to_fit(new_width, new_height)
122
+ raise ArgumentError, "must supply the width and/or height" if new_width.nil? && new_height.nil?
123
+ if new_height.nil? || (!new_width.nil? && height*new_width < width*new_height)
124
+ return [new_width, height*new_width/width]
125
+ else
126
+ return [width*new_height/height, new_height]
127
+ end
128
+ end
129
+
130
+ # scales the image proportionately so that it fits within the given width and height
131
+ # (ie. one dimension will be equal to the given dimension, and the other dimension
132
+ # will be smaller than the given other dimension). either (but not both) of the new
133
+ # width & height may be nil, in which case the image will be scaled solely based on
134
+ # the other parameter. yields this image itself if it is already the appropriate size.
135
+ def scale_to_fit(new_width, new_height, &block)
136
+ squish(*scale_dimensions_to_fit(new_width, new_height), &block)
137
+ end
138
+
139
+ # keeps proportions, as for scale_to_fit, but only ever makes images smaller.
140
+ # yields this image itself if it is already within the given dimensions.
141
+ def shrink_to_fit(new_width, new_height, &block)
142
+ new_width, new_height = scale_dimensions_to_fit(new_width, new_height)
143
+ return block.call(self) if new_width >= width && new_height >= height
144
+ squish(new_width, new_height, &block)
145
+ end
146
+
147
+ # keeps proportions, as for scale_to_fit, but only ever makes images bigger.
148
+ # yields this image itself if it is already within the given dimensions or if the
149
+ # scaled dimensions would be smaller than the current dimensions.
150
+ # this is one of the operations specified by the *magick geometry strings, but
151
+ # IMHO it's not particularly useful as it doesn't establish any particularly helpful
152
+ # postconditions; consider whether scale_to_cover would be more appropriate.
153
+ def expand_to_fit(new_width, new_height, &block)
154
+ new_width, new_height = scale_dimensions_to_fit(new_width, new_height)
155
+ return block.call(self) if new_width <= width && new_height <= height
156
+ squish(new_width, new_height, &block)
157
+ end
158
+
159
+ # scales the image proportionately so that it fits over the given width and height (ie.
160
+ # one dimension will be equal to the given dimension, and the other dimension will be
161
+ # larger than the given other dimension). either (but not both) of the new width &
162
+ # height may be nil, in which case the image will be scaled solely based on the other
163
+ # parameter (in this case the result is the same as using scale_to_fit).
164
+ # yields this image itself if it is already the appropriate size.
165
+ def scale_to_cover(new_width, new_height, &block)
166
+ raise ArgumentError, "must supply the width and/or height" if new_width.nil? && new_height.nil?
167
+ if new_height.nil? || (!new_width.nil? && height*new_width > width*new_height)
168
+ squish(new_width, height*new_width/width, &block)
169
+ else
170
+ squish(width*new_height/height, new_height, &block)
171
+ end
172
+ end
173
+ # haven't seen any reason to implement shrink_to_cover and expand_to_cover yet, but could.
174
+
175
+ # scales the image proportionately to fit over the given width and height (as for
176
+ # scale_to_cover), then crops the image to the given width & height.
177
+ # yields this image itself if it is already the appropriate size.
178
+ def cover_and_crop(new_width, new_height, &block)
179
+ scale_to_cover(new_width, new_height) do |scaled|
180
+ return block.call(scaled) if new_width == scaled.width && new_height == scaled.height
181
+ scaled.crop_to(new_width || width, new_height || height, &block)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end