Module: SafeImage::DiscourseCompat

Defined in:
lib/safe_image/discourse_compat.rb

Overview

Compatibility-shaped API for the operations Discourse currently performs in OptimizedImage, UploadCreator, ShrinkUploadedImage and FileHelper. The backend is decided once by SafeImage.configure!; these methods only dispatch to it.

Constant Summary collapse

NATIVE_CONVERT_DEFAULT_QUALITY =

JPEG default when the caller passes no quality: matches what ImageMagick uses for sources without quality tables, rather than libvips’ Q75.

92
JPEGTRAN_OPERATIONS =

EXIF orientation values mapped onto jpegtran’s lossless transforms.

{
  2 => ["-flip", "horizontal"],
  3 => ["-rotate", "180"],
  4 => ["-flip", "vertical"],
  5 => ["-transpose"],
  6 => ["-rotate", "90"],
  7 => ["-transverse"],
  8 => ["-rotate", "270"]
}.freeze

Class Method Summary collapse

Class Method Details

.animated?(path, max_pixels: nil) ⇒ Boolean

Returns:

  • (Boolean)


387
388
389
# File 'lib/safe_image/discourse_compat.rb', line 387

def animated?(path, max_pixels: nil)
  frame_count(path, max_pixels: max_pixels).to_i > 1
end

.compat_backend_name(backend, info) ⇒ Object



258
259
260
261
# File 'lib/safe_image/discourse_compat.rb', line 258

def compat_backend_name(backend, info)
  base = backend.to_sym == :vips ? "libvips-direct" : "imagemagick"
  info[:encoder] == "cjpegli" ? "#{base}+cjpegli" : base
end

.compat_probe(path, max_pixels: nil) ⇒ Object



416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/safe_image/discourse_compat.rb', line 416

def compat_probe(path, max_pixels: nil)
  path = Pathname.new(path).expand_path.to_s
  if SafeImage.config.backend == :vips
    SafeImage.probe(path, max_pixels: max_pixels)
  else
    info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
    Result.new(
      input: path,
      output: nil,
      input_format: info.fetch(:input_format),
      output_format: nil,
      width: info.fetch(:width),
      height: info.fetch(:height),
      filesize: File.size(path),
      backend: "imagemagick",
      duration_ms: info.fetch(:duration_ms),
      optimizer: nil
    )
  end
end

.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
# File 'lib/safe_image/discourse_compat.rb', line 192

def convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
  max_pixels = SafeImage.resolved_max_pixels(max_pixels)
  output = PathSafety.ensure_safe_output_path!(to).to_s

  case SafeImage.config.backend
  when :vips
    native_convert(from, output, format: format, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
  when :imagemagick
    imagemagick_convert(from, output, format: format, quality: quality, optimize: optimize, max_pixels: max_pixels)
  end
end

.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil) ⇒ Object



356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/safe_image/discourse_compat.rb', line 356

def convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)
  max_pixels = SafeImage.resolved_max_pixels(max_pixels)
  output = PathSafety.ensure_safe_output_path!(to).to_s

  case SafeImage.config.backend
  when :vips
    # Pure-Ruby ICO parse; libvips only encodes the extracted pixels.
    info = Ico.convert_to_png(from, output, max_pixels: max_pixels)
    backend_name = "ico-ruby+libvips"
  when :imagemagick
    info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(from).expand_path.to_s, output: output)
    backend_name = "imagemagick"
  end
  Optimizer.optimize(output, mode: :lossless, strip_metadata: true) if optimize
  result_from_info(from, output, info, backend_name)
end

.convert_to_jpeg(from, to, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto) ⇒ Object



263
264
265
# File 'lib/safe_image/discourse_compat.rb', line 263

def convert_to_jpeg(from, to, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
  convert(from, to, format: "jpg", quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
end

.crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto) ⇒ Object



53
54
55
56
57
58
59
60
61
# File 'lib/safe_image/discourse_compat.rb', line 53

def crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
  max_pixels = SafeImage.resolved_max_pixels(max_pixels)
  case SafeImage.config.backend
  when :vips
    vips_crop(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
  when :imagemagick
    imagemagick_crop(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels)
  end
end

.downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto) ⇒ Object



119
120
121
122
123
124
125
126
127
# File 'lib/safe_image/discourse_compat.rb', line 119

def downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)
  max_pixels = SafeImage.resolved_max_pixels(max_pixels)
  case SafeImage.config.backend
  when :vips
    vips_downsize(from, to, dimensions, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
  when :imagemagick
    imagemagick_downsize(from, to, dimensions, optimize: optimize, max_pixels: max_pixels)
  end
end

.fix_orientation(from, to = from, max_pixels: nil, quality: nil) ⇒ Object



278
279
280
281
282
283
284
285
286
287
288
# File 'lib/safe_image/discourse_compat.rb', line 278

def fix_orientation(from, to = from, max_pixels: nil, quality: nil)
  max_pixels = SafeImage.resolved_max_pixels(max_pixels)
  output = PathSafety.ensure_safe_output_path!(to).to_s

  case SafeImage.config.backend
  when :vips
    native_fix_orientation(from, output, max_pixels: max_pixels, quality: quality)
  when :imagemagick
    imagemagick_fix_orientation(from, output, max_pixels: max_pixels)
  end
end

.frame_count(path, max_pixels: nil) ⇒ Object



373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/safe_image/discourse_compat.rb', line 373

def frame_count(path, max_pixels: nil)
  max_pixels = SafeImage.resolved_max_pixels(max_pixels)
  # ico directories are counted by the pure-Ruby parser on either backend;
  # everything else is a header-only count.
  return Ico.frame_count(path, max_pixels: max_pixels) if File.extname(PathSafety.local_path(path)).downcase == ".ico"

  case SafeImage.config.backend
  when :vips
    VipsBackend.frame_count(path, max_pixels: max_pixels)
  when :imagemagick
    ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
  end
end

.imagemagick_convert(from, output, format:, quality:, optimize:, max_pixels:) ⇒ Object



204
205
206
207
208
209
210
# File 'lib/safe_image/discourse_compat.rb', line 204

def imagemagick_convert(from, output, format:, quality:, optimize:, max_pixels:)
  probe = compat_probe(from, max_pixels: max_pixels)
  normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
  info = ImageMagickBackend.convert(input: probe.input, output: output, format: format, quality: quality)
  optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
  result_from_info(probe.input, output, info, "imagemagick")
end

.imagemagick_crop(from, to, width, height, quality:, optimize:, max_pixels:) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/safe_image/discourse_compat.rb', line 103

def imagemagick_crop(from, to, width, height, quality:, optimize:, max_pixels:)
  probe = compat_probe(from, max_pixels: max_pixels)
  output = PathSafety.ensure_safe_output_path!(to).to_s
  info = ImageMagickBackend.resize_like(
    input: probe.input,
    output: output,
    width: width,
    height: height,
    format: File.extname(output).delete_prefix(".").downcase,
    quality: quality,
    crop: :north
  )
  optimize_output(output, quality) if optimize
  result_from_info(probe.input, output, info, "imagemagick")
end

.imagemagick_downsize(from, to, dimensions, optimize:, max_pixels:) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/safe_image/discourse_compat.rb', line 166

def imagemagick_downsize(from, to, dimensions, optimize:, max_pixels:)
  probe = compat_probe(from, max_pixels: max_pixels)
  output = PathSafety.ensure_safe_output_path!(to).to_s
  info = ImageMagickBackend.downsize(
    input: probe.input,
    output: output,
    dimensions: dimensions,
    format: File.extname(output).delete_prefix(".").downcase
  )
  optimize_output(output, nil) if optimize
  result_from_info(probe.input, output, info, "imagemagick")
end

.imagemagick_fix_orientation(from, output, max_pixels:) ⇒ Object



290
291
292
293
294
# File 'lib/safe_image/discourse_compat.rb', line 290

def imagemagick_fix_orientation(from, output, max_pixels:)
  probe = compat_probe(from, max_pixels: max_pixels)
  info = ImageMagickBackend.fix_orientation(input: probe.input, output: output)
  result_from_info(probe.input, output, info, "imagemagick")
end

.imagemagick_resize(from, to, width, height, quality:, optimize:, max_pixels:) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/safe_image/discourse_compat.rb', line 38

def imagemagick_resize(from, to, width, height, quality:, optimize:, max_pixels:)
  probe = compat_probe(from, max_pixels: max_pixels)
  output = PathSafety.ensure_safe_output_path!(to).to_s
  info = ImageMagickBackend.thumbnail(
    input: probe.input,
    output: output,
    width: width,
    height: height,
    format: File.extname(output).delete_prefix(".").downcase,
    quality: quality
  )
  optimize_output(output, quality) if optimize
  result_from_info(probe.input, output, info, "imagemagick")
end

.jpegtran_fix_orientation(input, output, orient) ⇒ Object



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/safe_image/discourse_compat.rb', line 323

def jpegtran_fix_orientation(input, output, orient)
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  info = write_through_tempfile(output) do |tmp_path|
    Runner.run!(["jpegtran", "-copy", "none", "-perfect", *JPEGTRAN_OPERATIONS.fetch(orient), "-outfile", tmp_path, input])
    Native.probe(tmp_path)
  end
  result_from_info(
    input,
    output,
    {
      input_format: "jpg",
      output_format: "jpg",
      width: info.fetch(:width),
      height: info.fetch(:height),
      duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
    },
    "jpegtran"
  )
end

.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans") ⇒ Object



391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/safe_image/discourse_compat.rb', line 391

def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
  output = PathSafety.ensure_safe_output_path!(output).to_s
  request = { output: output, size: size, background_rgb: background_rgb, letter: letter, pointsize: pointsize, font: font }

  info, backend_name =
    case SafeImage.config.backend
    when :vips
      [VipsBackend.letter_avatar(**request), "libvips-direct"]
    when :imagemagick
      [ImageMagickBackend.letter_avatar(**request), "imagemagick"]
    end

  result_from_info("generated", output, info, backend_name)
end

.native_convert(from, output, format:, quality:, optimize:, max_pixels:, chroma_subsampling:) ⇒ Object



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/safe_image/discourse_compat.rb', line 212

def native_convert(from, output, format:, quality:, optimize:, max_pixels:, chroma_subsampling:)
  input = PathSafety.ensure_regular_file!(from).to_s
  normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase

  if use_jpegli_for_convert?(input, normalized_format)
    info = JpegliBackend.convert(
      input: input,
      output: output,
      quality: quality || JpegliBackend::DEFAULT_QUALITY,
      chroma_subsampling: chroma_subsampling
    )
    return result_from_info(input, output, info, "cjpegli")
  end

  info = write_through_tempfile(output) do |tmp_path|
    Native.convert(input, tmp_path, normalized_format, quality || NATIVE_CONVERT_DEFAULT_QUALITY, max_pixels)
  end
  optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
  result_from_info(input, output, info, "libvips-direct")
end

.native_fix_orientation(from, output, max_pixels:, quality:) ⇒ Object

Raises:

  • (ArgumentError)


296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/safe_image/discourse_compat.rb', line 296

def native_fix_orientation(from, output, max_pixels:, quality:)
  input = PathSafety.ensure_regular_file!(from).to_s
  format = File.extname(input).delete_prefix(".").downcase
  format = "jpg" if format == "jpeg"
  # Validates the format against the native loader allowlist and enforces
  # the pixel cap before any pixel decode.
  orient = VipsBackend.orientation(input, max_pixels: max_pixels)

  # Lossless tier: jpegtran transforms JPEG DCT coefficients directly, so
  # there is no generation loss. -perfect refuses when the dimensions are
  # not MCU-aligned; fall through to the re-encode tier.
  if format == "jpg" && orient > 1 && Runner.available?("jpegtran")
    begin
      return jpegtran_fix_orientation(input, output, orient)
    rescue CommandError
      nil
    end
  end

  quality = quality.nil? ? 95 : Integer(quality)
  raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
  info = write_through_tempfile(output) do |tmp_path|
    Native.resize(input, tmp_path, 1.0, format, quality, max_pixels)
  end
  result_from_info(input, output, info, "libvips-direct")
end

.optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true) ⇒ Object



406
407
408
409
410
411
412
413
414
# File 'lib/safe_image/discourse_compat.rb', line 406

def optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)
  Optimizer.optimize(
    path,
    mode: allow_lossy_png ? :lossy : :lossless,
    strip_metadata: ,
    quality: quality,
    strict: strict
  )
end

.optimize_output(output, quality) ⇒ Object

Post-processing applies only to the formats the optimizer tools understand; other outputs (gif, jxl, …) skip the pass.



181
182
183
184
185
186
# File 'lib/safe_image/discourse_compat.rb', line 181

def optimize_output(output, quality)
  format = File.extname(output).delete_prefix(".").downcase
  format = "jpg" if format == "jpeg"
  return unless Processor::OPTIMIZABLE_OUTPUTS.include?(format)
  Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: quality)
end

.resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto) ⇒ Object



15
16
17
18
19
20
21
22
23
# File 'lib/safe_image/discourse_compat.rb', line 15

def resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
  max_pixels = SafeImage.resolved_max_pixels(max_pixels)
  case SafeImage.config.backend
  when :vips
    vips_resize(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
  when :imagemagick
    imagemagick_resize(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels)
  end
end

.result_from_info(input, output, info, backend) ⇒ Object



437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'lib/safe_image/discourse_compat.rb', line 437

def result_from_info(input, output, info, backend)
  Result.new(
    input: input.to_s,
    output: output.to_s,
    input_format: info.fetch(:input_format),
    output_format: info.fetch(:output_format),
    width: info.fetch(:width),
    height: info.fetch(:height),
    filesize: File.size(output),
    backend: backend,
    duration_ms: info.fetch(:duration_ms),
    optimizer: nil
  )
end

.use_jpegli_for_convert?(input, normalized_format) ⇒ Boolean

Returns:

  • (Boolean)


233
234
235
# File 'lib/safe_image/discourse_compat.rb', line 233

def use_jpegli_for_convert?(input, normalized_format)
  normalized_format == "jpg" && JpegliBackend.available? && JpegliBackend.suitable_direct_input?(input)
end

.use_jpegli_for_generated_jpeg?(format) ⇒ Boolean

cjpegli is an output-quality tool, not a configuration choice: installed means used for JPEG output on the native path. It encodes only pixels this gem already decoded, so it is not part of the untrusted-input surface the backend choice controls.

Returns:

  • (Boolean)


241
242
243
244
# File 'lib/safe_image/discourse_compat.rb', line 241

def use_jpegli_for_generated_jpeg?(format)
  normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
  normalized_format == "jpg" && JpegliBackend.available?
end

.vips_crop(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/safe_image/discourse_compat.rb', line 63

def vips_crop(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:)
  probe = compat_probe(from, max_pixels: max_pixels)
  output = PathSafety.ensure_safe_output_path!(to).to_s
  format = File.extname(output).delete_prefix(".").downcase

  info =
    if use_jpegli_for_generated_jpeg?(format)
      with_temp_png(output) do |tmp_path|
        VipsBackend.crop_north(
          input: probe.input,
          output: tmp_path,
          width: width,
          height: height,
          format: "png",
          quality: 100,
          max_pixels: max_pixels
        )
        JpegliBackend.encode(
          input: tmp_path,
          output: output,
          quality: quality || JpegliBackend::DEFAULT_QUALITY,
          chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(chroma_subsampling, input_format: probe.input_format),
          input_format: probe.input_format
        )
      end
    else
      VipsBackend.crop_north(
        input: probe.input,
        output: output,
        width: width,
        height: height,
        format: format,
        quality: quality || 85,
        max_pixels: max_pixels
      )
    end
  optimize_output(output, quality) if optimize
  result_from_info(probe.input, output, info, compat_backend_name(:vips, info))
end

.vips_downsize(from, to, dimensions, quality:, optimize:, max_pixels:, chroma_subsampling:) ⇒ Object



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/safe_image/discourse_compat.rb', line 129

def vips_downsize(from, to, dimensions, quality:, optimize:, max_pixels:, chroma_subsampling:)
  probe = compat_probe(from, max_pixels: max_pixels)
  output = PathSafety.ensure_safe_output_path!(to).to_s
  format = File.extname(output).delete_prefix(".").downcase
  info =
    if use_jpegli_for_generated_jpeg?(format)
      with_temp_png(output) do |tmp_path|
        VipsBackend.downsize(
          input: probe.input,
          output: tmp_path,
          dimensions: dimensions,
          format: "png",
          quality: 100,
          max_pixels: max_pixels
        )
        JpegliBackend.encode(
          input: tmp_path,
          output: output,
          quality: quality,
          chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(chroma_subsampling, input_format: probe.input_format),
          input_format: probe.input_format
        )
      end
    else
      VipsBackend.downsize(
        input: probe.input,
        output: output,
        dimensions: dimensions,
        format: format,
        quality: quality,
        max_pixels: max_pixels
      )
    end
  optimize_output(output, nil) if optimize
  result_from_info(probe.input, output, info, compat_backend_name(:vips, info))
end

.vips_resize(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:) ⇒ Object



25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/safe_image/discourse_compat.rb', line 25

def vips_resize(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:)
  SafeImage.thumbnail(
    input: from,
    output: to,
    width: width,
    height: height,
    quality: quality || 85,
    optimize: optimize,
    max_pixels: max_pixels,
    chroma_subsampling: chroma_subsampling
  )
end

.with_temp_png(output) ⇒ Object



246
247
248
249
250
251
252
253
254
255
256
# File 'lib/safe_image/discourse_compat.rb', line 246

def with_temp_png(output)
  output_path = Pathname.new(output)
  output_path.dirname.mkpath
  Tempfile.create([output_path.basename(".*").to_s, ".safe-image.png"], output_path.dirname.to_s) do |tmp|
    tmp_path = Pathname.new(tmp.path)
    tmp.close
    yield tmp_path
  ensure
    FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
  end
end

.write_through_tempfile(output) ⇒ Object

Writes via a sibling tempfile and renames into place, so in-place calls (to == from) never feed an output path that libvips is still reading from as input.



346
347
348
349
350
351
352
353
354
# File 'lib/safe_image/discourse_compat.rb', line 346

def write_through_tempfile(output)
  tmp_path = File.join(File.dirname(output), ".safe-image-#{Process.pid}-#{output.object_id}#{File.extname(output)}")
  PathSafety.ensure_safe_output_path!(tmp_path)
  result = yield tmp_path
  FileUtils.mv(tmp_path, output)
  result
ensure
  FileUtils.rm_f(tmp_path)
end