jr-paperclip 7.3.0 → 8.0.0.beta.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 +4 -4
- data/.github/workflows/{test.yml → tests.yml} +19 -9
- data/.rubocop.yml +2 -1
- data/CONTRIBUTING.md +1 -1
- data/Gemfile +1 -0
- data/NEWS +16 -1
- data/README.md +119 -8
- data/UPGRADING +5 -0
- data/VIPS_MIGRATION_GUIDE.md +131 -0
- data/features/basic_integration.feature +27 -0
- data/features/step_definitions/attachment_steps.rb +17 -0
- data/gemfiles/7.0.gemfile +1 -0
- data/gemfiles/7.1.gemfile +1 -0
- data/gemfiles/7.2.gemfile +1 -0
- data/gemfiles/8.0.gemfile +1 -0
- data/gemfiles/8.1.gemfile +1 -0
- data/lib/paperclip/attachment.rb +3 -2
- data/lib/paperclip/errors.rb +4 -5
- data/lib/paperclip/geometry.rb +3 -3
- data/lib/paperclip/geometry_detector_factory.rb +52 -12
- data/lib/paperclip/helpers.rb +18 -0
- data/lib/paperclip/processor.rb +36 -4
- data/lib/paperclip/thumbnail.rb +568 -62
- data/lib/paperclip/version.rb +1 -1
- data/lib/paperclip.rb +26 -9
- data/paperclip.gemspec +3 -2
- data/spec/paperclip/attachment_definitions_spec.rb +300 -0
- data/spec/paperclip/attachment_spec.rb +1 -1
- data/spec/paperclip/geometry_detector_spec.rb +81 -32
- data/spec/paperclip/geometry_spec.rb +8 -5
- data/spec/paperclip/helpers_spec.rb +49 -0
- data/spec/paperclip/lazy_thumbnail_compatibility_spec.rb +266 -0
- data/spec/paperclip/processor_spec.rb +35 -1
- data/spec/paperclip/style_spec.rb +58 -0
- data/spec/paperclip/thumbnail_custom_options_spec.rb +173 -0
- data/spec/paperclip/thumbnail_loader_options_spec.rb +53 -0
- data/spec/paperclip/thumbnail_security_spec.rb +42 -0
- data/spec/paperclip/thumbnail_spec.rb +1127 -172
- metadata +36 -4
data/lib/paperclip/thumbnail.rb
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
|
|
1
3
|
module Paperclip
|
|
2
4
|
# Handles thumbnailing images that are uploaded.
|
|
5
|
+
# Now uses the image_processing gem internally, supporting both
|
|
6
|
+
# ImageMagick (via MiniMagick) and libvips backends.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage (unchanged from before)
|
|
9
|
+
# has_attached_file :avatar,
|
|
10
|
+
# styles: { medium: "300x300>", thumb: "100x100#" }
|
|
11
|
+
#
|
|
12
|
+
# @example Using libvips backend for better performance
|
|
13
|
+
# has_attached_file :avatar,
|
|
14
|
+
# styles: { medium: "300x300>", thumb: "100x100#" },
|
|
15
|
+
# backend: :vips
|
|
16
|
+
#
|
|
17
|
+
# @example Per-style backend selection
|
|
18
|
+
# has_attached_file :document,
|
|
19
|
+
# styles: {
|
|
20
|
+
# preview: { geometry: "800x800>", backend: :vips },
|
|
21
|
+
# thumb: { geometry: "100x100#", backend: :image_magick }
|
|
22
|
+
# }
|
|
23
|
+
#
|
|
3
24
|
class Thumbnail < Processor
|
|
4
|
-
|
|
5
|
-
|
|
25
|
+
# Backward-compatible attributes (same as original Thumbnail)
|
|
26
|
+
attr_accessor :current_geometry, :target_geometry, :format, :whiny,
|
|
27
|
+
:convert_options, :source_file_options, :animated,
|
|
28
|
+
:auto_orient, :frame_index
|
|
6
29
|
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
MULTI_FRAME_FORMATS = %w(.mkv .avi .mp4 .mov .mpg .mpeg .gif).freeze
|
|
30
|
+
# New attributes
|
|
31
|
+
attr_accessor :backend
|
|
10
32
|
|
|
33
|
+
ANIMATED_FORMATS = %w(gif).freeze
|
|
11
34
|
# Creates a Thumbnail object set to work on the +file+ given. It
|
|
12
35
|
# will attempt to transform the image into one defined by +target_geometry+
|
|
13
36
|
# which is a "WxH"-style string. +format+ will be inferred from the +file+
|
|
@@ -16,7 +39,7 @@ module Paperclip
|
|
|
16
39
|
# set, the options will be appended to the convert command upon image conversion
|
|
17
40
|
#
|
|
18
41
|
# Options include:
|
|
19
|
-
#
|
|
42
|
+
# +backend+ - image_magick or vips, fallbacks to Paperclip.options[:backend]
|
|
20
43
|
# +geometry+ - the desired width and height of the thumbnail (required)
|
|
21
44
|
# +file_geometry_parser+ - an object with a method named +from_file+ that takes an image file and produces its geometry and a +transformation_to+. Defaults to Paperclip::Geometry
|
|
22
45
|
# +string_geometry_parser+ - an object with a method named +parse+ that takes a string and produces an object with +width+, +height+, and +to_s+ accessors. Defaults to Paperclip::Geometry
|
|
@@ -26,26 +49,69 @@ module Paperclip
|
|
|
26
49
|
# +format+ - the desired filename extension
|
|
27
50
|
# +animated+ - whether to merge all the layers in the image. Defaults to true
|
|
28
51
|
# +frame_index+ - the frame index of the source file to render as the thumbnail
|
|
52
|
+
MULTI_FRAME_FORMATS = %w(.mkv .avi .mp4 .mov .mpg .mpeg .gif .pdf).freeze
|
|
53
|
+
|
|
54
|
+
# Like ActiveStorage we want to be careful on what options are allowed for ImageMagick
|
|
55
|
+
# 2 additional options added to Active Storage default list: set and profile
|
|
56
|
+
# https://github.com/advisories/GHSA-r4mg-4433-c7g3
|
|
57
|
+
ALLOWED_IMAGEMAGICK_OPTIONS = %w(
|
|
58
|
+
adaptive_blur adaptive_resize adaptive_sharpen adjoin affine alpha annotate antialias append
|
|
59
|
+
attenuate authenticate auto_gamma auto_level auto_orient auto_threshold backdrop background
|
|
60
|
+
bench bias bilateral_blur black_point_compensation black_threshold blend blue_primary
|
|
61
|
+
blue_shift blur border bordercolor borderwidth brightness_contrast cache canny caption
|
|
62
|
+
channel channel_fx charcoal chop clahe clamp clip clip_path clone clut coalesce colorize
|
|
63
|
+
colormap color_matrix colors colorspace colourspace color_threshold combine combine_options
|
|
64
|
+
comment compare complex compose composite compress connected_components contrast
|
|
65
|
+
contrast_stretch convert convolve copy crop cycle deconstruct define delay delete density
|
|
66
|
+
depth descend deskew despeckle direction displace dispose dissimilarity_threshold dissolve
|
|
67
|
+
distort dither draw duplicate edge emboss encoding endian enhance equalize evaluate
|
|
68
|
+
evaluate_sequence extent extract family features fft fill filter flatten flip floodfill
|
|
69
|
+
flop font foreground format frame function fuzz fx gamma gaussian_blur geometry gravity
|
|
70
|
+
grayscale green_primary hald_clut highlight_color hough_lines iconGeometry iconic identify
|
|
71
|
+
ift illuminant immutable implode insert intensity intent interlace interline_spacing
|
|
72
|
+
interpolate interpolative_resize interword_spacing kerning kmeans kuwahara label lat layers
|
|
73
|
+
level level_colors limit limits linear_stretch linewidth liquid_rescale list log loop
|
|
74
|
+
lowlight_color magnify map mattecolor median mean_shift metric mode modulate moments
|
|
75
|
+
monitor monochrome morph morphology mosaic motion_blur name negate noise normalize opaque
|
|
76
|
+
ordered_dither orient page paint pause perceptible ping pointsize polaroid poly posterize
|
|
77
|
+
precision preview process profile quality quantize quiet radial_blur raise random_threshold
|
|
78
|
+
range_threshold red_primary regard_warnings region remote render repage resample resize
|
|
79
|
+
resize_to_fill resize_to_fit resize_to_limit resize_and_pad respect_parentheses reverse
|
|
80
|
+
roll rotate sample sampling_factor scale scene screen seed segment selective_blur separate
|
|
81
|
+
sepia_tone set shade shadow shared_memory sharpen shave shear sigmoidal_contrast silent
|
|
82
|
+
similarity_threshold size sketch smush snaps solarize sort_pixels sparse_color splice
|
|
83
|
+
spread statistic stegano stereo storage_type stretch strip stroke strokewidth style
|
|
84
|
+
subimage_search swap swirl synchronize taint text_font threshold thumbnail tile_offset tint
|
|
85
|
+
title transform transparent transparent_color transpose transverse treedepth trim type
|
|
86
|
+
undercolor unique_colors units unsharp update valid_image view vignette virtual_pixel
|
|
87
|
+
visual watermark wave wavelet_denoise weight white_balance white_point white_threshold
|
|
88
|
+
window window_group
|
|
89
|
+
).freeze
|
|
90
|
+
|
|
29
91
|
def initialize(file, options = {}, attachment = nil)
|
|
30
92
|
super
|
|
31
93
|
|
|
32
|
-
geometry
|
|
33
|
-
@crop
|
|
34
|
-
@target_geometry
|
|
35
|
-
@
|
|
94
|
+
geometry = options[:geometry].to_s
|
|
95
|
+
@crop = geometry[-1, 1] == "#"
|
|
96
|
+
@target_geometry = options.fetch(:string_geometry_parser, Geometry).parse(geometry)
|
|
97
|
+
@whiny = options.fetch(:whiny, true)
|
|
98
|
+
@format = options[:format]
|
|
99
|
+
@animated = options.fetch(:animated, true)
|
|
100
|
+
@auto_orient = options.fetch(:auto_orient, true)
|
|
101
|
+
|
|
102
|
+
# Backward-compatible options
|
|
103
|
+
@convert_options = options[:convert_options]
|
|
36
104
|
@source_file_options = options[:source_file_options]
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@
|
|
40
|
-
|
|
41
|
-
@
|
|
105
|
+
|
|
106
|
+
# New options
|
|
107
|
+
@backend = resolve_backend(options)
|
|
108
|
+
|
|
109
|
+
@current_geometry = options.fetch(:file_geometry_parser, Geometry).from_file(@file, @backend)
|
|
42
110
|
@current_geometry.auto_orient if @auto_orient && @current_geometry.respond_to?(:auto_orient)
|
|
43
|
-
@source_file_options = @source_file_options.split(/\s+/) if @source_file_options.respond_to?(:split)
|
|
44
|
-
@convert_options = @convert_options.split(/\s+/) if @convert_options.respond_to?(:split)
|
|
45
111
|
|
|
46
|
-
@current_format
|
|
47
|
-
@basename
|
|
48
|
-
@frame_index
|
|
112
|
+
@current_format = File.extname(@file.path)
|
|
113
|
+
@basename = File.basename(@file.path, @current_format)
|
|
114
|
+
@frame_index = multi_frame_format? ? options.fetch(:frame_index, 0) : 0
|
|
49
115
|
end
|
|
50
116
|
|
|
51
117
|
# Returns true if the +target_geometry+ is meant to crop.
|
|
@@ -54,48 +120,47 @@ module Paperclip
|
|
|
54
120
|
end
|
|
55
121
|
|
|
56
122
|
# Returns true if the image is meant to make use of additional convert options.
|
|
123
|
+
# Backwards-compatible method from original Thumbnail.
|
|
57
124
|
def convert_options?
|
|
58
|
-
|
|
125
|
+
@convert_options.present?
|
|
59
126
|
end
|
|
60
127
|
|
|
61
|
-
# Performs the conversion of the +file+ into a thumbnail. Returns the Tempfile
|
|
62
|
-
# that contains the new image.
|
|
63
128
|
def make
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
129
|
+
source_path = File.expand_path(@file.path)
|
|
130
|
+
extension = @format ? ".#{@format}" : @current_format
|
|
131
|
+
filename = [@basename, extension].join
|
|
132
|
+
destination = nil
|
|
67
133
|
|
|
68
134
|
begin
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
message = "There was an error processing the thumbnail for #{@basename}:\n" + e.message
|
|
87
|
-
raise Paperclip::Error, message
|
|
135
|
+
destination = TempfileFactory.new.generate(filename)
|
|
136
|
+
pipeline = build_pipeline(source_path)
|
|
137
|
+
pipeline.call(destination: destination.path)
|
|
138
|
+
destination
|
|
139
|
+
rescue LoadError => e
|
|
140
|
+
destination&.close! if destination.respond_to?(:close!)
|
|
141
|
+
raise Paperclip::Errors::CommandNotFoundError.new("Could not run the command for #{backend}. Please install dependencies.")
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
destination&.close! if destination.respond_to?(:close!)
|
|
144
|
+
if defined?(::Vips::Error) && e.is_a?(::Vips::Error)
|
|
145
|
+
handle_error(e, "libvips")
|
|
146
|
+
elsif defined?(::MiniMagick::Error) && (e.is_a?(::MiniMagick::Error) || e.is_a?(::MiniMagick::Invalid))
|
|
147
|
+
handle_error(e, "ImageMagick")
|
|
148
|
+
elsif defined?(::ImageProcessing::Error) && e.is_a?(::ImageProcessing::Error)
|
|
149
|
+
handle_error(e, "ImageProcessing")
|
|
150
|
+
else
|
|
151
|
+
raise e
|
|
88
152
|
end
|
|
89
|
-
rescue Terrapin::CommandNotFoundError => e
|
|
90
|
-
raise Paperclip::Errors::CommandNotFoundError.new("Could not run the `convert` command. Please install ImageMagick.")
|
|
91
153
|
end
|
|
92
|
-
|
|
93
|
-
dst
|
|
94
154
|
end
|
|
95
155
|
|
|
96
156
|
# Returns the command ImageMagick's +convert+ needs to transform the image
|
|
97
|
-
# into the thumbnail.
|
|
157
|
+
# into the thumbnail. Provided for backwards compatibility.
|
|
158
|
+
# @deprecated This method is deprecated and does not reflect actual processing.
|
|
98
159
|
def transformation_command
|
|
160
|
+
if backend == :vips
|
|
161
|
+
Paperclip.log("Warning: transformation_command called but using vips backend")
|
|
162
|
+
end
|
|
163
|
+
|
|
99
164
|
scale, crop = @current_geometry.transformation_to(@target_geometry, crop?)
|
|
100
165
|
trans = []
|
|
101
166
|
trans << "-coalesce" if animated?
|
|
@@ -106,26 +171,467 @@ module Paperclip
|
|
|
106
171
|
trans
|
|
107
172
|
end
|
|
108
173
|
|
|
109
|
-
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def resolve_backend(options)
|
|
177
|
+
candidate = options[:backend] ||
|
|
178
|
+
attachment&.options&.dig(:backend) ||
|
|
179
|
+
Paperclip.options[:backend]
|
|
180
|
+
Paperclip.resolve_backend(candidate)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def build_pipeline(source_path)
|
|
184
|
+
pipeline = image_processing_module.source(source_path)
|
|
185
|
+
|
|
186
|
+
# Handle source file options
|
|
187
|
+
if @source_file_options
|
|
188
|
+
loader_options = parse_loader_options(@source_file_options)
|
|
189
|
+
pipeline = pipeline.loader(**loader_options) unless loader_options.empty?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Handle multi-layer formats (like PDF or animated GIF)
|
|
193
|
+
# If we are not processing animation, we usually want the first frame.
|
|
194
|
+
# image_processing defaults to processing all layers for some formats.
|
|
195
|
+
if !animated? && multi_frame_format?
|
|
196
|
+
if backend == :image_magick
|
|
197
|
+
# For PDFs or multi-frame images where we want a static thumbnail:
|
|
198
|
+
pipeline = pipeline.loader(page: @frame_index)
|
|
199
|
+
elsif backend == :vips
|
|
200
|
+
# Vips: load only the specified frame
|
|
201
|
+
pipeline = pipeline.loader(page: @frame_index, n: 1)
|
|
202
|
+
end
|
|
203
|
+
elsif animated?
|
|
204
|
+
if backend == :image_magick
|
|
205
|
+
# Explicitly load all pages for animation
|
|
206
|
+
pipeline = pipeline.loader(page: nil)
|
|
207
|
+
elsif backend == :vips
|
|
208
|
+
# Vips: load all pages
|
|
209
|
+
pipeline = pipeline.loader(n: -1)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Auto-orient based on EXIF data
|
|
214
|
+
if auto_orient
|
|
215
|
+
pipeline = if backend == :vips
|
|
216
|
+
pipeline.autorot
|
|
217
|
+
else
|
|
218
|
+
pipeline.auto_orient
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Apply resize operation
|
|
223
|
+
if target_geometry
|
|
224
|
+
pipeline = apply_resize(pipeline)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Handle animated images (GIF, WebP)
|
|
228
|
+
if animated?
|
|
229
|
+
if backend == :image_magick
|
|
230
|
+
pipeline = pipeline.coalesce.layers("optimize")
|
|
231
|
+
elsif backend == :vips
|
|
232
|
+
# There isn't optimize available the same way as for ImageMagick
|
|
233
|
+
pipeline = pipeline.saver(keep_duplicate_frames: false)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Format conversion
|
|
238
|
+
if format
|
|
239
|
+
pipeline = pipeline.convert(format.to_s)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Apply any additional custom convert options
|
|
243
|
+
# Some options work on both backends, others are ImageMagick-only
|
|
244
|
+
if convert_options?
|
|
245
|
+
pipeline = apply_convert_options(pipeline)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
pipeline
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def image_processing_module
|
|
252
|
+
case backend
|
|
253
|
+
when :vips
|
|
254
|
+
require "image_processing/vips"
|
|
255
|
+
ImageProcessing::Vips
|
|
256
|
+
else
|
|
257
|
+
# :image_magick or any other value defaults to ImageMagick
|
|
258
|
+
require "image_processing/mini_magick"
|
|
259
|
+
ImageProcessing::MiniMagick
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def apply_resize(pipeline)
|
|
264
|
+
width = target_geometry.width&.to_i
|
|
265
|
+
height = target_geometry.height&.to_i
|
|
266
|
+
modifier = target_geometry.modifier
|
|
267
|
+
|
|
268
|
+
# Handle special geometry cases
|
|
269
|
+
case modifier
|
|
270
|
+
when "#" # Crop to fill
|
|
271
|
+
if width && width > 0 && height && height > 0
|
|
272
|
+
pipeline.resize_to_fill(width, height)
|
|
273
|
+
elsif width && width > 0
|
|
274
|
+
pipeline.resize_to_fill(width, width)
|
|
275
|
+
elsif height && height > 0
|
|
276
|
+
pipeline.resize_to_fill(height, height)
|
|
277
|
+
else
|
|
278
|
+
pipeline
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
when ">" # Only shrink larger images
|
|
282
|
+
if width && width > 0 && height && height > 0
|
|
283
|
+
pipeline.resize_to_limit(width, height)
|
|
284
|
+
elsif width && width > 0
|
|
285
|
+
pipeline.resize_to_limit(width, nil)
|
|
286
|
+
elsif height && height > 0
|
|
287
|
+
pipeline.resize_to_limit(nil, height)
|
|
288
|
+
else
|
|
289
|
+
pipeline
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
when "<" # Only enlarge smaller images
|
|
293
|
+
# image_processing doesn't have direct support for this
|
|
294
|
+
# We need to check current dimensions first
|
|
295
|
+
if current_geometry && should_enlarge?
|
|
296
|
+
pipeline.resize_to_fit(width, height)
|
|
297
|
+
else
|
|
298
|
+
pipeline
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
when "!" # Exact dimensions (ignore aspect ratio)
|
|
302
|
+
if width && width > 0 && height && height > 0
|
|
303
|
+
if backend == :vips
|
|
304
|
+
if current_geometry
|
|
305
|
+
scale_x = width.to_f / current_geometry.width
|
|
306
|
+
scale_y = height.to_f / current_geometry.height
|
|
307
|
+
pipeline.custom { |img| img.resize(scale_x, vscale: scale_y) }
|
|
308
|
+
else
|
|
309
|
+
pipeline.resize_to_fill(width, height)
|
|
310
|
+
end
|
|
311
|
+
else
|
|
312
|
+
# MiniMagick: use resize with ! modifier
|
|
313
|
+
pipeline.resize("#{width}x#{height}!")
|
|
314
|
+
end
|
|
315
|
+
else
|
|
316
|
+
pipeline
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
when "^" # Minimum dimensions (fill the box, may overflow)
|
|
320
|
+
apply_minimum_dimensions(pipeline, width, height)
|
|
321
|
+
|
|
322
|
+
when "%" # Percentage resize
|
|
323
|
+
apply_percentage_resize(pipeline, width)
|
|
324
|
+
|
|
325
|
+
when "@" # Area-based resize (limit total pixels)
|
|
326
|
+
apply_area_resize(pipeline, width, false)
|
|
327
|
+
|
|
328
|
+
when "@>", ">@" # Area-based resize, only shrink
|
|
329
|
+
apply_area_resize(pipeline, width, true)
|
|
330
|
+
|
|
331
|
+
else
|
|
332
|
+
# Default: resize to fit (can enlarge, maintains aspect ratio)
|
|
333
|
+
if width && width > 0 && height && height > 0
|
|
334
|
+
pipeline.resize_to_fit(width, height)
|
|
335
|
+
elsif width && width > 0
|
|
336
|
+
pipeline.resize_to_fit(width, nil)
|
|
337
|
+
elsif height && height > 0
|
|
338
|
+
pipeline.resize_to_fit(nil, height)
|
|
339
|
+
else
|
|
340
|
+
pipeline
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def apply_minimum_dimensions(pipeline, width, height)
|
|
346
|
+
if backend == :vips && width && width > 0 && height && height > 0 && current_geometry
|
|
347
|
+
scale = [width.to_f / current_geometry.width, height.to_f / current_geometry.height].max
|
|
348
|
+
new_width = (current_geometry.width * scale).round
|
|
349
|
+
new_height = (current_geometry.height * scale).round
|
|
350
|
+
pipeline.resize_to_fit(new_width, new_height)
|
|
351
|
+
elsif width && width > 0 && height && height > 0
|
|
352
|
+
# MiniMagick: use resize with ^ modifier
|
|
353
|
+
if backend == :image_magick
|
|
354
|
+
pipeline.resize("#{width}x#{height}^")
|
|
355
|
+
else
|
|
356
|
+
pipeline.resize_to_fill(width, height, crop: :centre)
|
|
357
|
+
end
|
|
358
|
+
else
|
|
359
|
+
pipeline.resize_to_fit(width, height)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def apply_percentage_resize(pipeline, percentage)
|
|
364
|
+
scale = (percentage || 100) / 100.0
|
|
365
|
+
if current_geometry
|
|
366
|
+
new_width = (current_geometry.width * scale).round
|
|
367
|
+
new_height = (current_geometry.height * scale).round
|
|
368
|
+
pipeline.resize_to_fit(new_width, new_height)
|
|
369
|
+
else
|
|
370
|
+
pipeline
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def apply_area_resize(pipeline, max_area, only_shrink)
|
|
375
|
+
return pipeline unless current_geometry && max_area && max_area > 0
|
|
376
|
+
|
|
377
|
+
current_area = current_geometry.width * current_geometry.height
|
|
378
|
+
|
|
379
|
+
# If only_shrink and current image is smaller, return unchanged
|
|
380
|
+
if only_shrink && current_area <= max_area
|
|
381
|
+
return pipeline
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Calculate new dimensions maintaining aspect ratio
|
|
385
|
+
if !only_shrink || current_area > max_area
|
|
386
|
+
scale = Math.sqrt(max_area.to_f / current_area)
|
|
387
|
+
new_width = (current_geometry.width * scale).round
|
|
388
|
+
new_height = (current_geometry.height * scale).round
|
|
389
|
+
pipeline.resize_to_fit(new_width, new_height)
|
|
390
|
+
else
|
|
391
|
+
pipeline
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def apply_convert_options(pipeline)
|
|
396
|
+
# Parse convert_options into individual tokens
|
|
397
|
+
# Handle both string format "-strip -quality 80" and array format ["-strip", "-quality", "80"]
|
|
398
|
+
# Use Shellwords to properly handle quoted values like "-annotate 'Hello World'"
|
|
399
|
+
tokens = if @convert_options.is_a?(String)
|
|
400
|
+
Shellwords.shellsplit(@convert_options)
|
|
401
|
+
else
|
|
402
|
+
Array(@convert_options)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
i = 0
|
|
406
|
+
while i < tokens.size
|
|
407
|
+
token = tokens[i]
|
|
408
|
+
|
|
409
|
+
unless token.start_with?("-") || token.start_with?("+")
|
|
410
|
+
# Handle raw argument (e.g. part of a multi-arg sequence like -set k v)
|
|
411
|
+
if backend == :image_magick
|
|
412
|
+
pipeline = pipeline.append(token)
|
|
413
|
+
end
|
|
414
|
+
# Skip non-option tokens (shouldn't happen normally)
|
|
415
|
+
i += 1
|
|
416
|
+
next
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Remove leading dash(es) or plus(es) and convert to method-friendly format
|
|
420
|
+
opt_name = token.sub(/^[-+]+/, "")
|
|
421
|
+
prefix = token.start_with?("+") ? "+" : "-"
|
|
422
|
+
|
|
423
|
+
# Check if next token is a value
|
|
424
|
+
# Allow negative/positive numbers as values
|
|
425
|
+
next_token = i + 1 < tokens.size ? tokens[i + 1] : nil
|
|
426
|
+
has_value = next_token && (
|
|
427
|
+
(!next_token.start_with?("-") && !next_token.start_with?("+")) ||
|
|
428
|
+
next_token.match?(/^[-+]\d/)
|
|
429
|
+
)
|
|
430
|
+
value = has_value ? next_token : nil
|
|
431
|
+
|
|
432
|
+
# Apply the option - works for both backends where supported
|
|
433
|
+
pipeline = apply_single_option(pipeline, opt_name, value, prefix)
|
|
434
|
+
|
|
435
|
+
# Advance past this option (and its value if present)
|
|
436
|
+
i += has_value ? 2 : 1
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
pipeline
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def apply_single_option(pipeline, opt_name, value, prefix = "-")
|
|
443
|
+
if backend == :vips
|
|
444
|
+
# Vips doesn't support +options generally
|
|
445
|
+
if prefix == "+"
|
|
446
|
+
Paperclip.log("Warning: +#{opt_name} is not supported with vips backend, skipping")
|
|
447
|
+
return pipeline
|
|
448
|
+
end
|
|
449
|
+
# Normalize option name (handle hyphenated versions) to underscores for Vips methods
|
|
450
|
+
opt_name = opt_name.tr("-", "_")
|
|
451
|
+
apply_vips_option(pipeline, opt_name, value)
|
|
452
|
+
else
|
|
453
|
+
# ImageMagick expects hyphens (e.g. -auto-orient, -sampling-factor)
|
|
454
|
+
opt_name = opt_name.tr("_", "-")
|
|
455
|
+
apply_imagemagick_option(pipeline, opt_name, value, prefix)
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def apply_vips_option(pipeline, opt_name, value)
|
|
460
|
+
# Cross-platform options with vips-specific implementations
|
|
461
|
+
case opt_name
|
|
462
|
+
when "strip"
|
|
463
|
+
pipeline.saver(strip: true)
|
|
464
|
+
when "quality"
|
|
465
|
+
value ? pipeline.saver(quality: value.to_i) : pipeline
|
|
466
|
+
when "rotate"
|
|
467
|
+
# Vips rotate via similarity for arbitrary angles
|
|
468
|
+
if value
|
|
469
|
+
angle = value.to_f
|
|
470
|
+
pipeline.custom { |img| img.similarity(angle: angle) }
|
|
471
|
+
else
|
|
472
|
+
pipeline
|
|
473
|
+
end
|
|
474
|
+
when "flip"
|
|
475
|
+
# Vips uses flip with direction
|
|
476
|
+
pipeline.custom(&:flipver)
|
|
477
|
+
when "flop"
|
|
478
|
+
pipeline.custom(&:fliphor)
|
|
479
|
+
when "blur"
|
|
480
|
+
# Vips uses gaussblur with sigma parameter
|
|
481
|
+
# ImageMagick blur is "radiusxsigma", extract sigma
|
|
482
|
+
sigma = extract_blur_sigma(value)
|
|
483
|
+
sigma ? pipeline.custom { |img| img.gaussblur(sigma) } : pipeline
|
|
484
|
+
when "gaussian_blur"
|
|
485
|
+
sigma = extract_blur_sigma(value)
|
|
486
|
+
sigma ? pipeline.custom { |img| img.gaussblur(sigma) } : pipeline
|
|
487
|
+
when "sharpen"
|
|
488
|
+
# Vips sharpen has different parameters than ImageMagick
|
|
489
|
+
# Use sensible defaults for unsharp masking
|
|
490
|
+
pipeline.custom(&:sharpen)
|
|
491
|
+
when "colorspace"
|
|
492
|
+
# Vips uses British spelling "colourspace"
|
|
493
|
+
value ? pipeline.custom { |img| img.colourspace(vips_colorspace(value)) } : pipeline
|
|
494
|
+
when "flatten"
|
|
495
|
+
pipeline.custom { |img| img.flatten(background: [255, 255, 255]) }
|
|
496
|
+
when "negate", "invert"
|
|
497
|
+
pipeline.custom(&:invert)
|
|
498
|
+
when "auto_orient"
|
|
499
|
+
pipeline.autorot
|
|
500
|
+
when "interlace"
|
|
501
|
+
# Vips handles interlacing via saver options
|
|
502
|
+
pipeline.saver(interlace: true)
|
|
503
|
+
else
|
|
504
|
+
# Unknown option - log warning for vips
|
|
505
|
+
Paperclip.log("Warning: -#{opt_name} is not supported with vips backend, skipping")
|
|
506
|
+
pipeline
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def apply_imagemagick_option(pipeline, opt_name, value, prefix = "-")
|
|
511
|
+
# Check against allowed options (using underscore version for checking)
|
|
512
|
+
normalized_opt_name = opt_name.tr("-", "_")
|
|
513
|
+
unless ALLOWED_IMAGEMAGICK_OPTIONS.include?(normalized_opt_name)
|
|
514
|
+
Paperclip.log("Warning: Option #{opt_name} is not allowed.")
|
|
515
|
+
return pipeline
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# ImageMagick options are just CLI arguments, so we can simply append them.
|
|
519
|
+
# This handles standard options (-strip), plus options (+profile), and unknown options uniformly.
|
|
520
|
+
pipeline = pipeline.append("#{prefix}#{opt_name}")
|
|
521
|
+
pipeline = pipeline.append(value) if value
|
|
522
|
+
pipeline
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Extract sigma value from ImageMagick blur format "radiusxsigma" or just "sigma"
|
|
526
|
+
def extract_blur_sigma(value)
|
|
527
|
+
return nil unless value
|
|
528
|
+
|
|
529
|
+
if value.include?("x")
|
|
530
|
+
value.split("x").last.to_f
|
|
531
|
+
else
|
|
532
|
+
value.to_f
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Map ImageMagick colorspace names to Vips interpretation
|
|
537
|
+
def vips_colorspace(im_colorspace)
|
|
538
|
+
case im_colorspace.to_s.downcase
|
|
539
|
+
when "gray", "grey", "grayscale"
|
|
540
|
+
:grey16 # or :b_w for 1-bit
|
|
541
|
+
when "srgb", "rgb"
|
|
542
|
+
:srgb
|
|
543
|
+
when "cmyk"
|
|
544
|
+
:cmyk
|
|
545
|
+
when "lab"
|
|
546
|
+
:lab
|
|
547
|
+
when "xyz"
|
|
548
|
+
:xyz
|
|
549
|
+
else
|
|
550
|
+
:srgb # Default fallback
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Parses command-line style options into a hash for loader options.
|
|
555
|
+
# Example: "-density 300" becomes { density: "300" }
|
|
556
|
+
def parse_loader_options(options)
|
|
557
|
+
return options if options.is_a?(Hash)
|
|
558
|
+
|
|
559
|
+
result = {}
|
|
560
|
+
# Use Shellwords to properly handle quoted values
|
|
561
|
+
parts = if options.is_a?(String)
|
|
562
|
+
Shellwords.shellsplit(options)
|
|
563
|
+
else
|
|
564
|
+
Array(options)
|
|
565
|
+
end
|
|
566
|
+
i = 0
|
|
567
|
+
while i < parts.size
|
|
568
|
+
part = parts[i]
|
|
569
|
+
if part.start_with?("-")
|
|
570
|
+
key = part[1..].to_sym
|
|
571
|
+
|
|
572
|
+
next_part = parts[i + 1]
|
|
573
|
+
if i + 1 < parts.size && (!next_part.start_with?("-") || next_part.match?(/^-\d/))
|
|
574
|
+
result[key] = next_part
|
|
575
|
+
i += 2
|
|
576
|
+
else
|
|
577
|
+
result[key] = true
|
|
578
|
+
i += 1
|
|
579
|
+
end
|
|
580
|
+
else
|
|
581
|
+
i += 1
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
result
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def should_enlarge?
|
|
588
|
+
return false unless current_geometry && target_geometry
|
|
589
|
+
|
|
590
|
+
target_width = target_geometry.width.to_i
|
|
591
|
+
target_height = target_geometry.height.to_i
|
|
592
|
+
|
|
593
|
+
(target_width == 0 || current_geometry.width < target_width) &&
|
|
594
|
+
(target_height == 0 || current_geometry.height < target_height)
|
|
595
|
+
end
|
|
110
596
|
|
|
111
597
|
def multi_frame_format?
|
|
112
|
-
MULTI_FRAME_FORMATS.include?
|
|
598
|
+
MULTI_FRAME_FORMATS.include?(@current_format.downcase)
|
|
113
599
|
end
|
|
114
600
|
|
|
115
601
|
def animated?
|
|
116
|
-
@animated && (ANIMATED_FORMATS.include?(@format.to_s) || @format.blank?) &&
|
|
602
|
+
@animated && (ANIMATED_FORMATS.include?(@format.to_s.downcase) || @format.blank?) && animated_source?
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def animated_source?
|
|
606
|
+
@animated_source ||= begin
|
|
607
|
+
case backend
|
|
608
|
+
when :vips
|
|
609
|
+
vips_image(File.expand_path(@file.path)).get("n-pages").to_i > 1
|
|
610
|
+
when :image_magick
|
|
611
|
+
identify("-format %n :file", file: File.expand_path(@file.path)).to_i > 1
|
|
612
|
+
else
|
|
613
|
+
extension_indicates_animation?
|
|
614
|
+
end
|
|
615
|
+
rescue StandardError
|
|
616
|
+
extension_indicates_animation?
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def extension_indicates_animation?
|
|
621
|
+
ANIMATED_FORMATS.include?(@current_format.downcase.delete("."))
|
|
117
622
|
end
|
|
118
623
|
|
|
119
|
-
#
|
|
120
|
-
def
|
|
121
|
-
if @
|
|
122
|
-
|
|
624
|
+
# Handle processing errors - matches original Thumbnail's pattern
|
|
625
|
+
def handle_error(error, backend_name)
|
|
626
|
+
if @whiny
|
|
627
|
+
# Sanitize basename to avoid leaking full paths
|
|
628
|
+
safe_name = File.basename(@basename.to_s)
|
|
629
|
+
message = "There was an error processing the thumbnail for #{safe_name} using #{backend_name}:\n#{error.message}"
|
|
630
|
+
raise Paperclip::Error, message
|
|
631
|
+
else
|
|
632
|
+
Paperclip.log("Processing failed: #{error.message}")
|
|
633
|
+
@file
|
|
123
634
|
end
|
|
124
|
-
@identified_as_animated
|
|
125
|
-
rescue Terrapin::ExitStatusError => e
|
|
126
|
-
raise Paperclip::Error, "There was an error running `identify` for #{@basename}" if @whiny
|
|
127
|
-
rescue Terrapin::CommandNotFoundError => e
|
|
128
|
-
raise Paperclip::Errors::CommandNotFoundError.new("Could not run the `identify` command. Please install ImageMagick.")
|
|
129
635
|
end
|
|
130
636
|
end
|
|
131
637
|
end
|