jr-paperclip 7.3.1 → 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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/tests.yml +3 -1
  3. data/CONTRIBUTING.md +1 -1
  4. data/Gemfile +1 -0
  5. data/NEWS +13 -0
  6. data/README.md +116 -6
  7. data/UPGRADING +5 -0
  8. data/VIPS_MIGRATION_GUIDE.md +131 -0
  9. data/features/basic_integration.feature +27 -0
  10. data/features/step_definitions/attachment_steps.rb +17 -0
  11. data/gemfiles/7.0.gemfile +1 -0
  12. data/gemfiles/7.1.gemfile +1 -0
  13. data/gemfiles/7.2.gemfile +1 -0
  14. data/gemfiles/8.0.gemfile +1 -0
  15. data/gemfiles/8.1.gemfile +1 -0
  16. data/lib/paperclip/attachment.rb +3 -2
  17. data/lib/paperclip/errors.rb +4 -5
  18. data/lib/paperclip/geometry.rb +3 -3
  19. data/lib/paperclip/geometry_detector_factory.rb +52 -12
  20. data/lib/paperclip/helpers.rb +18 -0
  21. data/lib/paperclip/processor.rb +36 -4
  22. data/lib/paperclip/thumbnail.rb +568 -62
  23. data/lib/paperclip/version.rb +1 -1
  24. data/lib/paperclip.rb +26 -9
  25. data/paperclip.gemspec +2 -1
  26. data/spec/paperclip/attachment_definitions_spec.rb +300 -0
  27. data/spec/paperclip/attachment_spec.rb +1 -1
  28. data/spec/paperclip/geometry_detector_spec.rb +81 -32
  29. data/spec/paperclip/geometry_spec.rb +8 -5
  30. data/spec/paperclip/helpers_spec.rb +49 -0
  31. data/spec/paperclip/lazy_thumbnail_compatibility_spec.rb +266 -0
  32. data/spec/paperclip/processor_spec.rb +35 -1
  33. data/spec/paperclip/style_spec.rb +58 -0
  34. data/spec/paperclip/thumbnail_custom_options_spec.rb +173 -0
  35. data/spec/paperclip/thumbnail_loader_options_spec.rb +53 -0
  36. data/spec/paperclip/thumbnail_security_spec.rb +42 -0
  37. data/spec/paperclip/thumbnail_spec.rb +1127 -172
  38. metadata +34 -2
@@ -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
- attr_accessor :current_geometry, :target_geometry, :format, :whiny, :convert_options,
5
- :source_file_options, :animated, :auto_orient, :frame_index
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
- # List of formats that we need to preserve animation
8
- ANIMATED_FORMATS = %w(gif).freeze
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 = options[:geometry].to_s
33
- @crop = geometry[-1, 1] == "#"
34
- @target_geometry = options.fetch(:string_geometry_parser, Geometry).parse(geometry)
35
- @current_geometry = options.fetch(:file_geometry_parser, Geometry).from_file(@file)
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
- @convert_options = options[:convert_options]
38
- @whiny = options.fetch(:whiny, true)
39
- @format = options[:format]
40
- @animated = options.fetch(:animated, true)
41
- @auto_orient = options.fetch(:auto_orient, true)
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 = File.extname(@file.path)
47
- @basename = File.basename(@file.path, @current_format)
48
- @frame_index = multi_frame_format? ? options.fetch(:frame_index, 0) : 0
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
- !@convert_options.nil? && !@convert_options.empty?
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
- src = @file
65
- filename = [@basename, @format ? ".#{@format}" : ""].join
66
- dst = TempfileFactory.new.generate(filename)
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
- parameters = []
70
- parameters << source_file_options
71
- parameters << ":source"
72
- parameters << transformation_command
73
- parameters << convert_options
74
- parameters << ":dest"
75
-
76
- parameters = parameters.flatten.compact.join(" ").strip.squeeze(" ")
77
-
78
- frame = animated? ? "" : "[#{@frame_index}]"
79
- convert(
80
- parameters,
81
- source: "#{File.expand_path(src.path)}#{frame}",
82
- dest: File.expand_path(dst.path)
83
- )
84
- rescue Terrapin::ExitStatusError => e
85
- if @whiny
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
- protected
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? @current_format
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?) && identified_as_animated?
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
- # Return true if ImageMagick's +identify+ returns an animated format
120
- def identified_as_animated?
121
- if @identified_as_animated.nil?
122
- @identified_as_animated = ANIMATED_FORMATS.include? identify("-format %m :file", file: "#{@file.path}[0]").to_s.downcase.strip
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