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,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