jekyll-imgflow 0.3.2 → 0.4.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "pathname"
5
+ require "fileutils"
5
6
 
6
7
  module JekyllImgFlow
7
8
  module Providers
@@ -10,6 +11,15 @@ module JekyllImgFlow
10
11
  # Valid smartcrop position values
11
12
  SMARTCROP_POSITIONS = %w[attention entropy center centre].freeze
12
13
 
14
+ # Short compass → provider position mapping used by HTTP providers.
15
+ COMPASS_TO_SHORT = {
16
+ "northwest" => "tl",
17
+ "northeast" => "tr",
18
+ "southwest" => "bl",
19
+ "southeast" => "br",
20
+ "center" => "c"
21
+ }.freeze
22
+
13
23
  attr_accessor :config
14
24
 
15
25
  def initialize(config = {})
@@ -17,6 +27,86 @@ module JekyllImgFlow
17
27
  @operations = []
18
28
  end
19
29
 
30
+ # Operation lookup helpers — used by all providers to avoid
31
+ # repeating `@operations.find { |op| op[:type] == :xxx }`.
32
+ def find_op(type)
33
+ @operations.find { |op| op[:type] == type }
34
+ end
35
+
36
+ def op?(type)
37
+ @operations.any? { |op| op[:type] == type }
38
+ end
39
+
40
+ # Normalize a crop operation into a geometry hash.
41
+ # Returns { smartcrop:, keep:, x:, y:, width:, height: } where
42
+ # smartcrop is true when ratio + keep + valid smartcrop position.
43
+ def crop_geometry(operation)
44
+ opts = operation[:options] || {}
45
+ params = operation[:params] || {}
46
+ keep = opts[:keep] || params[:keep] || params[:position]
47
+ smartcrop = operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
48
+ coords = if operation[:ratio]
49
+ { x: opts[:calculated_x], y: opts[:calculated_y],
50
+ width: opts[:calculated_width], height: opts[:calculated_height] }
51
+ else
52
+ { x: opts[:x] || 0, y: opts[:y] || 0,
53
+ width: opts[:width], height: opts[:height] }
54
+ end
55
+ coords.merge(smartcrop: smartcrop, keep: keep)
56
+ end
57
+
58
+ # Extract watermark operation parts into a hash.
59
+ def watermark_parts(operation)
60
+ {
61
+ watermark_path: operation[:watermark_path],
62
+ position: operation[:options][:position],
63
+ opacity: operation[:options][:opacity]
64
+ }
65
+ end
66
+
67
+ # Shared compass → short position mapping (tl/tr/bl/br/c).
68
+ # HTTP providers (imgproxy, weserv, flyimg) all use this mapping.
69
+ def compass_to_short(position)
70
+ COMPASS_TO_SHORT[position.to_s] || position.to_s
71
+ end
72
+
73
+ # Input format extension (lowercased, no dot) for format preservation.
74
+ def input_format_ext(input_path)
75
+ File.extname(input_path).delete(".").downcase
76
+ end
77
+
78
+ # Convert a 0..1 opacity float to a 0..255 integer (used by all
79
+ # providers that encode alpha as an 8-bit value).
80
+ def alpha_byte_value(opacity)
81
+ (opacity * 255).round
82
+ end
83
+
84
+ # Generate a temporary file path by replacing the extension of
85
+ # +input_path+ with +suffix+ (e.g. temp_path("a.jpg", "tmp_base.jpg")
86
+ # → "a.tmp_base.jpg").
87
+ def temp_path(input_path, suffix)
88
+ input_path.gsub(/\.[^.]+$/, ".#{suffix}")
89
+ end
90
+
91
+ # Map a smartcrop +keep+ value to the libvips interestingness string
92
+ # used by Sharp and LibVips smartcrop/thumbnail --crop operations.
93
+ def smartcrop_interestingness(keep)
94
+ case keep.to_s
95
+ when "entropy" then "entropy"
96
+ when "center", "centre" then "centre"
97
+ else "attention"
98
+ end
99
+ end
100
+
101
+ # Check whether any of the given CLI commands is available on PATH.
102
+ # Used by CLI providers (sharp, libvips, imagemagick).
103
+ def cli_available?(*commands)
104
+ commands.any? do |cmd|
105
+ _, _, status = Open3.capture3("which", cmd)
106
+ status.success?
107
+ end
108
+ end
109
+
20
110
  # Check if this provider is available (must be implemented by subclasses)
21
111
  def available?
22
112
  # Default: not available unless subclass implements actual check
@@ -165,6 +255,7 @@ module JekyllImgFlow
165
255
  end
166
256
 
167
257
  def execute_command(command)
258
+ # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- provider commands shell-escape paths; pipelines are intentional for Sharp watermark operations.
168
259
  stdout, stderr, status = Open3.capture3(command)
169
260
  raise "Command failed: #{command}\nError: #{stderr.strip}" unless status.success?
170
261
 
@@ -205,5 +296,113 @@ module JekyllImgFlow
205
296
  end
206
297
  end
207
298
  end
299
+
300
+ # Shared base for HTTP-based providers (imgproxy, weserv, flyimg).
301
+ # Subclasses must implement `service_url`, `build_combined_url`, and
302
+ # define a `TIMEOUT` constant. They may optionally override
303
+ # `provider_label` for error messages.
304
+ class HttpBase < BaseProvider
305
+ def available?
306
+ url = service_url
307
+ return false unless url
308
+
309
+ check_http_service(url)
310
+ end
311
+
312
+ def execute(input_path, output_path)
313
+ return if @operations.empty?
314
+
315
+ url = build_combined_url(input_path)
316
+ fetch_and_save(url, output_path)
317
+ output_path
318
+ ensure
319
+ reset_operations
320
+ end
321
+
322
+ protected
323
+
324
+ # Subclasses override to return the configured service URL or nil.
325
+ def service_url
326
+ raise NotImplementedError
327
+ end
328
+
329
+ # Subclasses override to build the full request URL.
330
+ def build_combined_url(_input_path)
331
+ raise NotImplementedError
332
+ end
333
+
334
+ # Label used in error messages (defaults to class name).
335
+ def provider_label
336
+ self.class.name.split("::").last
337
+ end
338
+
339
+ def timeout
340
+ self.class::TIMEOUT
341
+ end
342
+
343
+ def fetch_and_save(url, output_path)
344
+ response = fetch_with_timeout(url)
345
+ raise "#{provider_label} request failed" if response.empty?
346
+
347
+ FileUtils.mkdir_p(File.dirname(output_path))
348
+ File.write(output_path, response)
349
+ end
350
+
351
+ def fetch_with_timeout(url)
352
+ uri = URI(url)
353
+ Net::HTTP.start(uri.host, uri.port,
354
+ use_ssl: uri.scheme == "https",
355
+ open_timeout: timeout,
356
+ read_timeout: timeout) do |http|
357
+ request = Net::HTTP::Get.new(uri)
358
+ response = http.request(request)
359
+
360
+ raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
361
+
362
+ response.body
363
+ end
364
+ rescue StandardError => e
365
+ raise "#{provider_label} request failed: #{e.message}"
366
+ end
367
+ end
368
+
369
+ # Shared base for CLI-based providers (sharp, imagemagick, libvips).
370
+ # Subclasses implement `build_commands(input_path, output_path)` to
371
+ # return an array of command entries (strings or arrays), and
372
+ # `run_command(cmd)` to execute a single entry.
373
+ class CliBase < BaseProvider
374
+ def execute(input_path, output_path)
375
+ return if @operations.empty?
376
+
377
+ before_execute(input_path)
378
+ commands = build_commands(input_path, output_path)
379
+ Array(commands).each { |cmd| run_command(cmd) }
380
+ output_path
381
+ ensure
382
+ reset_operations
383
+ end
384
+
385
+ protected
386
+
387
+ # Hook for subclasses to perform pre-execution work (e.g. SVG warnings).
388
+ def before_execute(_input_path); end
389
+
390
+ # Subclasses override to return command(s) to execute.
391
+ # May return a single command or an array of commands.
392
+ def build_commands(_input_path, _output_path)
393
+ raise NotImplementedError
394
+ end
395
+
396
+ # Execute a single command. Default uses the shell-based
397
+ # `execute_command` from BaseProvider. Subclasses that use
398
+ # argument arrays (no shell) should override this.
399
+ def run_command(cmd)
400
+ if cmd.is_a?(Array) && cmd.first == :cleanup
401
+ FileUtils.rm_f(cmd[1])
402
+ return
403
+ end
404
+ execute_command(cmd)
405
+ end
406
+ end
208
407
  end
209
408
  end
@@ -2,144 +2,98 @@
2
2
 
3
3
  require "net/http"
4
4
  require "uri"
5
- require "tempfile"
6
5
  require_relative "base_provider"
7
6
 
8
7
  module JekyllImgFlow
9
8
  module Providers
10
9
  # Flyimg provider implementation using the standardized tag interface
11
- class Flyimg < BaseProvider
12
- TIMEOUT = 10
10
+ class Flyimg < HttpBase
11
+ TIMEOUT = 60
13
12
 
14
- def available?
15
- # Check if flyimg service is running
16
- return false unless @config.respond_to?(:flyimg_url) && @config.flyimg_url
17
-
18
- check_http_service(@config.flyimg_url)
13
+ def service_url
14
+ @config.respond_to?(:flyimg_url) ? @config.flyimg_url : nil
19
15
  end
20
16
 
21
- def execute(input_path, output_path)
22
- return if @operations.empty?
23
-
24
- # Build single Flyimg URL with all operations combined
25
- url = build_combined_flyimg_url(input_path)
26
-
27
- # Fetch the result in one request
28
- fetch_and_save(url, output_path)
29
- output_path
30
- ensure
31
- reset_operations
17
+ def build_combined_flyimg_url(input_path)
18
+ build_combined_url(input_path)
32
19
  end
33
20
 
34
- def build_combined_flyimg_url(input_path)
35
- raise "flyimg_url not set" unless @config.flyimg_url
21
+ def build_combined_url(input_path)
22
+ raise "flyimg_url not set" unless service_url
36
23
 
37
24
  encoded_url = encode_file_url(input_path)
38
25
  options = []
39
26
 
40
- # Add all operations to single options string
41
- @operations.each do |operation|
42
- case operation[:type]
43
- when :resize
44
- # Tags now provide complete calculated values.
45
- # c_1 forces the image to fill the exact width x height area.
46
- # pns_0 allows upscaling when the source is smaller than the target
47
- # (Flyimg's pns/preserve-natural-size defaults to 1, blocking enlarge).
48
- options << "w_#{operation[:width]}"
49
- options << "pns_0"
50
- if operation[:height]
51
- options << "h_#{operation[:height]}"
52
- options << "c_1"
53
- end
54
-
55
- when :crop
56
- opts = operation[:options]
57
- params = operation[:params] || {}
58
-
59
- # Check for keep parameter (smartcrop support)
60
- keep = opts[:keep] || params[:keep] || params[:position]
61
-
62
- if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
63
- # Use smartcrop (Flyimg's smc option picks the best crop area)
64
- crop_width = opts[:calculated_width]
65
- crop_height = opts[:calculated_height]
66
-
67
- options << "w_#{crop_width}"
68
- options << "h_#{crop_height}"
69
- options << "c_1"
70
- options << "smc_1"
71
- else
72
- # Use basic cropping via extract (top-left / bottom-right coordinates)
73
- if operation[:ratio]
74
- crop_x = opts[:calculated_x]
75
- crop_y = opts[:calculated_y]
76
- crop_width = opts[:calculated_width]
77
- crop_height = opts[:calculated_height]
78
- else
79
- crop_x = opts[:x]
80
- crop_y = opts[:y]
81
- crop_width = opts[:width]
82
- crop_height = opts[:height]
83
- end
84
- options << "e_1"
85
- options << "p1x_#{crop_x}"
86
- options << "p1y_#{crop_y}"
87
- options << "p2x_#{crop_x + crop_width}"
88
- options << "p2y_#{crop_y + crop_height}"
89
- end
90
-
91
- when :quality
92
- flyimg_quality = translate_quality_to_flyimg(operation[:quality])
93
- options << "q_#{flyimg_quality}"
94
-
95
- when :format
96
- # Assume validated input from tags
97
- options << "o_#{operation[:format]}"
98
-
99
- when :watermark
100
- watermark_path = operation[:watermark_path]
101
- position = operation[:options][:position]
102
- opacity = operation[:options][:opacity]
103
-
104
- # Flyimg watermark parameters
105
- encoded_watermark = encode_file_url(watermark_path)
106
- position_param = translate_flyimg_position(position)
107
- opacity_param = (opacity * 100).round
108
- options << "wm_#{encoded_watermark}_#{position_param}_#{opacity_param}"
109
-
110
- when :alpha_opacity
111
- opacity = operation[:opacity]
112
- options << "a_#{(opacity * 255).round}"
113
- end
114
- end
27
+ @operations.each { |operation| append_flyimg_operation(operation, options) }
28
+
29
+ preserve_input_format(input_path, options)
30
+ "#{service_url}/upload/#{options.join(',')}/#{encoded_url}"
31
+ end
115
32
 
116
- # Preserve format when no explicit format operation is requested.
117
- # Without this, chained HTTP requests (e.g. quality-only steps after a
118
- # prior format conversion) would lose the previously converted format.
119
- unless @operations.any? { |op| op[:type] == :format }
120
- input_ext = File.extname(input_path).delete(".").downcase
121
- options << "o_#{map_format(input_ext)}" unless input_ext.empty?
33
+ private
34
+
35
+ def append_flyimg_operation(operation, options)
36
+ case operation[:type]
37
+ when :resize
38
+ append_flyimg_resize(operation, options)
39
+ when :crop
40
+ append_flyimg_crop(operation, options)
41
+ when :quality
42
+ options << "q_#{operation[:quality]}"
43
+ when :format
44
+ options << "o_#{operation[:format]}"
45
+ when :watermark
46
+ append_flyimg_watermark(operation, options)
47
+ when :alpha_opacity
48
+ options << "a_#{alpha_byte_value(operation[:opacity])}"
122
49
  end
50
+ end
51
+
52
+ def append_flyimg_resize(operation, options)
53
+ # c_1 forces the image to fill the exact width x height area.
54
+ # pns_0 allows upscaling when the source is smaller than the target
55
+ # (Flyimg's pns/preserve-natural-size defaults to 1, blocking enlarge).
56
+ options << "w_#{operation[:width]}"
57
+ options << "pns_0"
58
+ return unless operation[:height]
123
59
 
124
- # Build combined URL: base/upload/options/options,.../encoded_url
125
- options_string = options.join(",")
126
- "#{@config.flyimg_url}/upload/#{options_string}/#{encoded_url}"
60
+ options << "h_#{operation[:height]}"
61
+ options << "c_1"
127
62
  end
128
63
 
129
- private
64
+ def append_flyimg_crop(operation, options)
65
+ geo = crop_geometry(operation)
130
66
 
131
- def translate_flyimg_position(position)
132
- # Translate compass directions to Flyimg position format
133
- case position
134
- when "northwest" then "tl"
135
- when "northeast" then "tr"
136
- when "southwest" then "bl"
137
- when "southeast" then "br"
138
- when "center" then "c"
139
- else position
67
+ if geo[:smartcrop]
68
+ options << "w_#{geo[:width]}"
69
+ options << "h_#{geo[:height]}"
70
+ options << "c_1"
71
+ options << "smc_1"
72
+ else
73
+ # Use basic cropping via extract (top-left / bottom-right coordinates)
74
+ options << "e_1"
75
+ options << "p1x_#{geo[:x]}"
76
+ options << "p1y_#{geo[:y]}"
77
+ options << "p2x_#{geo[:x] + geo[:width]}"
78
+ options << "p2y_#{geo[:y] + geo[:height]}"
140
79
  end
141
80
  end
142
81
 
82
+ def append_flyimg_watermark(operation, options)
83
+ parts = watermark_parts(operation)
84
+ encoded_watermark = encode_file_url(parts[:watermark_path])
85
+ position_param = compass_to_short(parts[:position])
86
+ opacity_param = (parts[:opacity] * 100).round
87
+ options << "wm_#{encoded_watermark}_#{position_param}_#{opacity_param}"
88
+ end
89
+
90
+ def preserve_input_format(input_path, options)
91
+ return if op?(:format)
92
+
93
+ ext = input_format_ext(input_path)
94
+ options << "o_#{map_format(ext)}" unless ext.empty?
95
+ end
96
+
143
97
  def map_format(format)
144
98
  case format&.downcase
145
99
  when "jpeg", "jpg"
@@ -155,33 +109,9 @@ module JekyllImgFlow
155
109
  end
156
110
  end
157
111
 
158
- def fetch_and_save(url, output_path)
159
- response = fetch_with_timeout(url)
160
- raise "Flyimg request failed" if response.empty?
161
-
162
- FileUtils.mkdir_p(File.dirname(output_path))
163
- File.write(output_path, response)
164
- end
165
-
166
- def fetch_with_timeout(url)
167
- uri = URI(url)
168
- Net::HTTP.start(uri.host, uri.port,
169
- use_ssl: uri.scheme == "https",
170
- open_timeout: TIMEOUT,
171
- read_timeout: TIMEOUT) do |http|
172
- request = Net::HTTP::Get.new(uri)
173
- response = http.request(request)
174
-
175
- raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
176
-
177
- response.body
178
- end
179
- rescue StandardError => e
180
- raise "Flyimg request failed: #{e.message}"
181
- end
182
-
183
- def translate_quality_to_flyimg(quality)
184
- quality
112
+ # Thin wrapper kept for test compatibility.
113
+ def translate_flyimg_position(position)
114
+ compass_to_short(position)
185
115
  end
186
116
  end
187
117
  end
@@ -7,31 +7,22 @@ require_relative "base_provider"
7
7
  module JekyllImgFlow
8
8
  module Providers
9
9
  # ImageMagick provider implementation using the standardized tag interface
10
- class Imagemagick < BaseProvider
10
+ class Imagemagick < CliBase
11
11
  # Cache rsvg delegate check across all instances (checked once per build)
12
12
  @rsvg_available = nil
13
13
  @svg_warning_shown = false
14
14
 
15
15
  def available?
16
- # Check if magick or convert CLI is available
17
- _, _, status1 = Open3.capture3("which", "magick")
18
- _, _, status2 = Open3.capture3("which", "convert")
19
- status1.success? || status2.success?
16
+ cli_available?("magick", "convert")
20
17
  end
21
18
 
22
- def execute(input_path, output_path)
23
- return if @operations.empty?
24
-
19
+ def before_execute(input_path)
25
20
  # Warn once per build about SVG performance with ImageMagick
26
21
  warn_svg_performance if svg?(input_path) && !self.class.instance_variable_get(:@svg_warning_shown)
22
+ end
27
23
 
28
- # Build single ImageMagick command with all operations combined
29
- command = build_combined_imagemagick_command(input_path, output_path)
30
- execute_command(command)
31
-
32
- output_path
33
- ensure
34
- reset_operations
24
+ def build_commands(input_path, output_path)
25
+ build_combined_imagemagick_command(input_path, output_path)
35
26
  end
36
27
 
37
28
  def build_combined_imagemagick_command(input_path, output_path)
@@ -47,85 +38,7 @@ module JekyllImgFlow
47
38
  ["magick", input_path.shellescape]
48
39
  end
49
40
 
50
- # Add all operations
51
- @operations.each do |operation|
52
- case operation[:type]
53
- when :resize
54
- # Tags now provide complete calculated values
55
- command_parts << "-resize" << if operation[:height]
56
- "#{operation[:width]}x#{operation[:height]}!"
57
- else
58
- operation[:width].to_s
59
- end
60
-
61
- when :crop
62
- opts = operation[:options]
63
- params = operation[:params] || {}
64
-
65
- # Check for keep parameter (ImageMagick doesn't support smartcrop, but we handle it gracefully)
66
- keep = opts[:keep] || params[:keep] || params[:position]
67
-
68
- if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
69
- # ImageMagick doesn't support smartcrop, but we handle the keep parameter
70
- # Use center gravity as a reasonable fallback for smartcrop requests
71
- crop_width = opts[:calculated_width]
72
- crop_height = opts[:calculated_height]
73
- crop_x = opts[:calculated_x]
74
- crop_y = opts[:calculated_y]
75
-
76
- # Use gravity center for smartcrop-like behavior
77
- crop_spec = "#{crop_width}x#{crop_height}+#{crop_x}+#{crop_y}"
78
- command_parts << "-gravity" << "center"
79
- else
80
- # Use basic cropping
81
- if operation[:ratio]
82
- crop_x = opts[:calculated_x]
83
- crop_y = opts[:calculated_y]
84
- crop_width = opts[:calculated_width]
85
- crop_height = opts[:calculated_height]
86
- else
87
- crop_x = opts[:x]
88
- crop_y = opts[:y]
89
- crop_width = opts[:width]
90
- crop_height = opts[:height]
91
- end
92
- crop_spec = "#{crop_width}x#{crop_height}+#{crop_x}+#{crop_y}"
93
- end
94
- command_parts << "-crop" << crop_spec
95
-
96
- when :quality
97
- magick_quality = translate_quality_to_imagemagick(operation[:quality])
98
- command_parts << "-quality" << magick_quality.to_s
99
-
100
- when :format
101
- # Assume validated input from tags
102
- # Format is handled by output filename extension
103
- # Quality is set separately if needed
104
- unless @operations.any? { |op| op[:type] == :quality }
105
- default_quality = @config&.quality || raise("No quality configured")
106
- command_parts << "-quality" << default_quality.to_s
107
- end
108
-
109
- when :watermark
110
- watermark_path = operation[:watermark_path]
111
- position = operation[:options][:position]
112
-
113
- # Translate compass directions to ImageMagick gravity format
114
- gravity = translate_position(position)
115
-
116
- # Add watermark as composite operation
117
- command_parts << watermark_path.shellescape
118
- command_parts << "-gravity" << gravity
119
- command_parts << "-composite"
120
-
121
- when :alpha_opacity
122
- opacity = operation[:opacity]
123
- alpha_value = (opacity * 100).round
124
- command_parts << "-alpha" << "set"
125
- command_parts << "-channel" << "A"
126
- command_parts << "-evaluate" << "multiply" << "#{alpha_value}%"
127
- end
128
- end
41
+ @operations.each { |operation| append_imagemagick_operation(operation, command_parts) }
129
42
 
130
43
  # Add output filename
131
44
  command_parts << output_path.shellescape
@@ -146,9 +59,56 @@ module JekyllImgFlow
146
59
 
147
60
  private
148
61
 
149
- def translate_quality_to_imagemagick(quality)
150
- # ImageMagick uses 1-100 directly, no translation needed
151
- quality
62
+ def append_imagemagick_operation(operation, command_parts)
63
+ case operation[:type]
64
+ when :resize
65
+ command_parts << "-resize" << if operation[:height]
66
+ "#{operation[:width]}x#{operation[:height]}!"
67
+ else
68
+ operation[:width].to_s
69
+ end
70
+ when :crop
71
+ append_imagemagick_crop(operation, command_parts)
72
+ when :quality
73
+ command_parts << "-quality" << operation[:quality].to_s
74
+ when :format
75
+ # Format is handled by output filename extension.
76
+ # Quality is set separately if needed.
77
+ unless op?(:quality)
78
+ default_quality = @config&.quality || raise("No quality configured")
79
+ command_parts << "-quality" << default_quality.to_s
80
+ end
81
+ when :watermark
82
+ append_imagemagick_watermark(operation, command_parts)
83
+ when :alpha_opacity
84
+ alpha_value = (operation[:opacity] * 100).round
85
+ command_parts << "-alpha" << "set"
86
+ command_parts << "-channel" << "A"
87
+ command_parts << "-evaluate" << "multiply" << "#{alpha_value}%"
88
+ end
89
+ end
90
+
91
+ def append_imagemagick_crop(operation, command_parts)
92
+ geo = crop_geometry(operation)
93
+
94
+ if geo[:smartcrop]
95
+ # ImageMagick doesn't support smartcrop, but we handle the keep parameter
96
+ # Use center gravity as a reasonable fallback for smartcrop requests
97
+ command_parts << "-gravity" << "center"
98
+ end
99
+
100
+ crop_spec = "#{geo[:width]}x#{geo[:height]}+#{geo[:x]}+#{geo[:y]}"
101
+ command_parts << "-crop" << crop_spec
102
+ end
103
+
104
+ def append_imagemagick_watermark(operation, command_parts)
105
+ parts = watermark_parts(operation)
106
+ gravity = translate_position(parts[:position])
107
+
108
+ # Add watermark as composite operation
109
+ command_parts << parts[:watermark_path].shellescape
110
+ command_parts << "-gravity" << gravity
111
+ command_parts << "-composite"
152
112
  end
153
113
 
154
114
  # Choose a rasterization density (DPI) for SVG input.