safe_image 0.2.0 → 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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -10
  3. data/README.md +176 -168
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +43 -41
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -281
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +250 -48
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +367 -97
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +69 -24
  30. data/lib/safe_image/sandbox.rb +44 -191
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +186 -55
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -294
  37. metadata +48 -26
  38. data/lib/safe_image/discourse_compat.rb +0 -452
  39. data/lib/safe_image/svg_sanitizer.rb +0 -102
  40. data/lib/safe_image/vips_glue.rb +0 -361
@@ -1,193 +1,118 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "vips_glue"
3
+ require "tempfile"
4
4
 
5
5
  module SafeImage
6
- # The libvips fast path, implemented in pure Ruby on top of the VipsGlue
7
- # Fiddle binding (formerly a compiled C extension; the function surface and
8
- # messages are unchanged). Loaders are explicit per extension, every decode
9
- # enforces the pixel cap from the header before pixel data is touched, and
10
- # all images are released deterministically.
6
+ # The libvips backend boundary. All libvips work is executed by the bundled
7
+ # safe_image_vips_helper process; the Ruby process never dlopens libvips.
8
+ # This module keeps the old Native call shape for the higher-level backend
9
+ # code while doing only argument normalization and response shaping in Ruby.
11
10
  module Native
12
- LOADERS = {
13
- "jpg" => "jpegload",
14
- "png" => "pngload",
15
- "webp" => "webpload",
16
- "gif" => "gifload",
17
- "heic" => "heifload",
18
- "avif" => "heifload",
19
- "jxl" => "jxlload"
20
- }.freeze
21
-
22
11
  class << self
23
- def probe(path)
24
- started = monotime
25
- VipsGlue.with_images do |track|
26
- image, format = load_image(track, String(path))
27
- {
28
- format: format,
29
- width: VipsGlue.width(image),
30
- height: VipsGlue.height(image),
31
- duration_ms: monotime - started
32
- }
33
- end
12
+ def available? = NativeHelper.available?
13
+
14
+ def probe(path, max_pixels = nil)
15
+ path = String(path)
16
+ input_format!(path)
17
+ info = NativeHelper.probe(path, checked_max_pixels(max_pixels))
18
+ {
19
+ format: info.fetch(:input_format),
20
+ width: info.fetch(:width),
21
+ height: info.fetch(:height),
22
+ duration_ms: info.fetch(:duration_ms)
23
+ }
34
24
  end
35
25
 
36
26
  def thumbnail(input, output, width, height, format, quality, max_pixels)
37
- started = monotime
38
- input = String(input)
39
- output = String(output)
40
27
  width = Integer(width)
41
28
  height = Integer(height)
42
29
  quality = Integer(quality)
43
30
  raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
44
31
  validate_quality!(quality)
45
- out_format = output_format!(format)
46
-
47
- VipsGlue.with_images do |track|
48
- # Header read through the explicit loader: validates the bytes and
49
- # enforces the pixel cap before any full decode.
50
- header, input_format = load_image(track, input)
51
- check_pixels!(header, max_pixels)
52
32
 
53
- # Thumbnail from the file so libvips can shrink on load (e.g.
54
- # libjpeg DCT downscaling); auto-rotates by default.
55
- thumb = track.call(
56
- VipsGlue.operation(
57
- "thumbnail",
58
- { filename: input, width: width, height: height,
59
- size: "both", crop: "centre", fail_on: "error" }
60
- )
61
- )
62
- save_image(thumb, output, out_format, quality)
63
- info(input_format, out_format, thumb, started)
64
- end
33
+ input = String(input)
34
+ input_format!(input)
35
+ NativeHelper.thumbnail(
36
+ input,
37
+ String(output),
38
+ width,
39
+ height,
40
+ output_format!(format),
41
+ quality,
42
+ checked_max_pixels(max_pixels)
43
+ )
65
44
  end
66
45
 
67
46
  def resize(input, output, scale, format, quality, max_pixels)
68
- started = monotime
69
47
  scale = Float(scale)
70
48
  quality = Integer(quality)
71
49
  unless scale.finite? && scale.positive? && scale <= 100.0
72
50
  raise ArgumentError, "scale must be finite and in 0..100"
73
51
  end
74
52
  validate_quality!(quality)
75
- out_format = output_format!(format)
76
53
 
77
- VipsGlue.with_images do |track|
78
- image, input_format = load_image(track, String(input))
79
- check_pixels!(image, max_pixels)
80
- rotated = track.call(VipsGlue.operation("autorot", { in: image }))
81
- resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
82
- save_image(resized, String(output), out_format, quality)
83
- info(input_format, out_format, resized, started)
84
- end
54
+ input = String(input)
55
+ input_format!(input)
56
+ NativeHelper.resize(
57
+ input,
58
+ String(output),
59
+ scale,
60
+ output_format!(format),
61
+ quality,
62
+ checked_max_pixels(max_pixels)
63
+ )
85
64
  end
86
65
 
87
66
  def crop_north(input, output, width, height, format, quality, max_pixels)
88
- started = monotime
89
67
  width = Integer(width)
90
68
  height = Integer(height)
91
69
  quality = Integer(quality)
92
70
  raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
93
71
  validate_quality!(quality)
94
- out_format = output_format!(format)
95
72
 
96
- VipsGlue.with_images do |track|
97
- image, input_format = load_image(track, String(input))
98
- check_pixels!(image, max_pixels)
99
- rotated = track.call(VipsGlue.operation("autorot", { in: image }))
100
-
101
- scale = [width.fdiv(VipsGlue.width(rotated)), height.fdiv(VipsGlue.height(rotated))].max * 1.0000001
102
- resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
103
- left = [(VipsGlue.width(resized) - width) / 2, 0].max
104
- cropped = track.call(
105
- VipsGlue.operation("extract_area", { input: resized, left: left, top: 0, width: width, height: height })
106
- )
107
- save_image(cropped, String(output), out_format, quality)
108
- info(input_format, out_format, cropped, started)
109
- end
73
+ input = String(input)
74
+ input_format!(input)
75
+ NativeHelper.crop_north(
76
+ input,
77
+ String(output),
78
+ width,
79
+ height,
80
+ output_format!(format),
81
+ quality,
82
+ checked_max_pixels(max_pixels)
83
+ )
110
84
  end
111
85
 
112
86
  def convert(input, output, format, quality, max_pixels)
113
- started = monotime
114
87
  quality = Integer(quality)
115
88
  validate_quality!(quality)
116
- out_format = output_format!(format)
117
-
118
- VipsGlue.with_images do |track|
119
- image, input_format = load_image(track, String(input))
120
- check_pixels!(image, max_pixels)
121
- rotated = track.call(VipsGlue.operation("autorot", { in: image }))
122
-
123
- # JPEG has no alpha; flatten onto white to match the ImageMagick
124
- # convert path (libvips composites onto black otherwise).
125
- final =
126
- if out_format == "jpg" && VipsGlue.alpha?(rotated)
127
- track.call(VipsGlue.operation("flatten", { in: rotated, background: [255.0, 255.0, 255.0] }))
128
- else
129
- rotated
130
- end
131
- save_image(final, String(output), out_format, quality)
132
- info(input_format, out_format, final, started)
133
- end
89
+ input = String(input)
90
+ input_format!(input)
91
+ NativeHelper.convert(input, String(output), output_format!(format), quality, checked_max_pixels(max_pixels))
134
92
  end
135
93
 
136
- # Alpha-weighted average colour as [r, g, b] integers. Premultiplying
137
- # keeps parity with ImageMagick's resize-based average; per-band means
138
- # come from vips_stats (row b+1, column 4 of the stats matrix).
139
94
  def dominant_color(path, max_pixels)
140
- VipsGlue.with_images do |track|
141
- image, = load_image(track, String(path))
142
- check_pixels!(image, max_pixels)
143
-
144
- srgb =
145
- if VipsGlue.colourspace_supported?(image)
146
- track.call(VipsGlue.operation("colourspace", { in: image, space: "srgb" }))
147
- else
148
- image
149
- end
150
- has_alpha = VipsGlue.alpha?(srgb)
151
- work = has_alpha ? track.call(VipsGlue.operation("premultiply", { in: srgb })) : srgb
152
-
153
- stats = track.call(VipsGlue.operation("stats", { in: work }))
154
- columns = VipsGlue.width(stats)
155
- matrix = VipsGlue.image_bytes(stats).unpack("d*")
156
- mean = ->(band) { matrix[(band + 1) * columns + 4] || 0.0 }
157
-
158
- bands = VipsGlue.bands(work)
159
- colour_bands = has_alpha ? bands - 1 : bands
160
- colour_bands = colour_bands.clamp(1, 3)
161
- raise InvalidImageError, "image has no colour bands" if colour_bands < 1
162
-
163
- alpha_mean = has_alpha ? mean.call(bands - 1) : 255.0
164
- (0...3).map do |band|
165
- value = mean.call([band, colour_bands - 1].min)
166
- value = alpha_mean.positive? ? value * 255.0 / alpha_mean : 0.0 if has_alpha
167
- value.round.clamp(0, 255)
168
- end
169
- end
95
+ path = String(path)
96
+ input_format!(path)
97
+ hex = NativeHelper.dominant_color(path, checked_max_pixels(max_pixels))
98
+ hex.scan(/../).map { |component| component.to_i(16) }
170
99
  end
171
100
 
172
101
  def pages(path, max_pixels)
173
- VipsGlue.with_images do |track|
174
- image, = load_image(track, String(path))
175
- check_pixels!(image, max_pixels)
176
- VipsGlue.pages(image)
177
- end
102
+ path = String(path)
103
+ input_format!(path)
104
+ NativeHelper.pages(path, checked_max_pixels(max_pixels))
178
105
  end
179
106
 
180
107
  def orientation(path, max_pixels)
181
- VipsGlue.with_images do |track|
182
- image, = load_image(track, String(path))
183
- check_pixels!(image, max_pixels)
184
- value = VipsGlue.orientation(image)
185
- (1..8).cover?(value) ? value : 1
186
- end
108
+ path = String(path)
109
+ input_format!(path)
110
+ NativeHelper.orientation(path, checked_max_pixels(max_pixels))
187
111
  end
188
112
 
189
- # Encodes a raw RGBA buffer (top-down rows) as PNG. Used by the
190
- # pure-Ruby ICO decoder.
113
+ # Encodes a raw RGBA buffer (top-down rows) as PNG. Used by the pure-Ruby
114
+ # ICO decoder; the raw bytes are staged to a tempfile and consumed by the
115
+ # helper so libvips remains out of the Ruby process.
191
116
  def png_from_rgba(bytes, width, height, output)
192
117
  bytes = String(bytes)
193
118
  width = Integer(width)
@@ -196,177 +121,67 @@ module SafeImage
196
121
  raise LimitError, "rgba buffer dimensions exceed 4096x4096" if width > 4096 || height > 4096
197
122
  raise ArgumentError, "rgba buffer must be width*height*4 bytes" if bytes.bytesize != width * height * 4
198
123
 
199
- VipsGlue.with_images do |track|
200
- image = track.call(VipsGlue.image_from_memory(bytes, width, height, 4, 0)) # 0 = uchar
201
- srgb = track.call(VipsGlue.operation("copy", { in: image, interpretation: "srgb" }))
202
- save_image(srgb, String(output), "png", 100)
124
+ Tempfile.create(%w[safe-image-rgba .rgba], binmode: true) do |raw|
125
+ raw.write(bytes)
126
+ raw.close
127
+ NativeHelper.png_from_rgba(raw.path, width, height, String(output))
203
128
  end
204
129
  true
205
130
  end
206
131
 
207
- # Renders a letter avatar: a Pango glyph mask blended in white at 80%
208
- # opacity over a solid background via a single linear transform. The
209
- # markup string is escaped by the Ruby caller; font and fontfile come
210
- # from an allowlist.
132
+ # Renders a letter avatar through the helper. Markup has already been
133
+ # escaped by VipsBackend; font tokens and font files come from its allowlist.
211
134
  def letter_avatar(output, size, red, green, blue, markup, font, fontfile)
212
135
  size = Integer(size)
213
- markup = String(markup)
214
- font = String(font)
215
- fontfile = String(fontfile)
216
136
  channels = [Integer(red), Integer(green), Integer(blue)]
217
137
  raise ArgumentError, "size must be 1..4096" unless (1..4096).cover?(size)
218
138
  unless channels.all? { |value| (0..255).cover?(value) }
219
139
  raise ArgumentError, "background channels must be 0..255"
220
140
  end
221
- unless VipsGlue.type_find?("text")
222
- raise UnsupportedFormatError, "this libvips build has no text renderer (Pango support missing)"
223
- end
224
-
225
- VipsGlue.with_images do |track|
226
- mask =
227
- if markup.empty?
228
- # Blank letter: solid background only.
229
- track.call(VipsGlue.operation("black", { width: size, height: size }))
230
- else
231
- text_inputs = { text: markup, font: font, dpi: 72 }
232
- text_inputs[:fontfile] = fontfile unless fontfile.empty?
233
- text = track.call(VipsGlue.operation("text", text_inputs))
234
141
 
235
- # vips_text returns the tight ink box; crop to the canvas when
236
- # the pointsize overflows it, then centre the ink optically.
237
- text_w = VipsGlue.width(text)
238
- text_h = VipsGlue.height(text)
239
- if text_w > size || text_h > size
240
- crop_w = [text_w, size].min
241
- crop_h = [text_h, size].min
242
- text = track.call(
243
- VipsGlue.operation(
244
- "extract_area",
245
- { input: text, left: (text_w - crop_w) / 2, top: (text_h - crop_h) / 2,
246
- width: crop_w, height: crop_h }
247
- )
248
- )
249
- text_w = crop_w
250
- text_h = crop_h
251
- end
252
- track.call(
253
- VipsGlue.operation(
254
- "embed",
255
- { in: text, x: (size - text_w) / 2, y: (size - text_h) / 2, width: size, height: size }
256
- )
257
- )
258
- end
259
-
260
- # blend = bg + (white - bg) * 0.8 * mask/255, one linear op.
261
- opacity = 204.0 / 255.0 # FFFFFFCC
262
- a = channels.map { |value| (255.0 - value) * opacity / 255.0 }
263
- blended = track.call(VipsGlue.operation("linear", { in: mask, a: a, b: channels.map(&:to_f) }))
264
- cast = track.call(VipsGlue.operation("cast", { in: blended, format: "uchar" }))
265
- srgb = track.call(VipsGlue.operation("copy", { in: cast, interpretation: "srgb" }))
266
- save_image(srgb, String(output), "png", 100)
267
- end
142
+ NativeHelper.letter_avatar(
143
+ String(output),
144
+ size,
145
+ channels[0],
146
+ channels[1],
147
+ channels[2],
148
+ String(markup),
149
+ String(font),
150
+ String(fontfile)
151
+ )
268
152
  true
269
153
  end
270
154
 
271
155
  private
272
156
 
273
- def monotime
274
- Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
275
- end
157
+ def input_format!(path)
158
+ format = Formats.extension(path)
159
+ raise UnsupportedFormatError, "unsupported input format" unless Formats.native_input?(format)
276
160
 
277
- def info(input_format, output_format, image, started)
278
- {
279
- input_format: input_format,
280
- output_format: output_format,
281
- width: VipsGlue.width(image),
282
- height: VipsGlue.height(image),
283
- duration_ms: monotime - started
284
- }
161
+ format
285
162
  end
286
163
 
287
- def normalized_format(ext)
288
- case ext.to_s.downcase
289
- when "jpg", "jpeg" then "jpg"
290
- when "png" then "png"
291
- when "webp" then "webp"
292
- when "gif" then "gif"
293
- when "heic", "heif" then "heic"
294
- when "avif" then "avif"
295
- when "jxl" then "jxl"
164
+ def output_format!(format)
165
+ normalized = Formats.normalize(String(format))
166
+ canonical = Formats.native_canonical(normalized)
167
+ unless Formats.native_output?(canonical) && canonical != "heic"
168
+ raise UnsupportedFormatError, "unsupported output format"
296
169
  end
297
- end
298
170
 
299
- def output_format!(format)
300
- normalized = normalized_format(String(format))
301
- raise UnsupportedFormatError, "unsupported output format" if normalized.nil? || normalized == "heic"
302
- normalized
171
+ canonical
303
172
  end
304
173
 
305
174
  def validate_quality!(quality)
306
175
  raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
307
176
  end
308
177
 
309
- def load_image(track, path)
310
- format = normalized_format(File.extname(path).delete_prefix("."))
311
- raise UnsupportedFormatError, "unsupported input format" unless format
312
-
313
- case format
314
- when "gif"
315
- # libnsgif loader: first frame only (the n=1 default), matching the
316
- # [0] semantics of the ImageMagick compatibility backend.
317
- raise UnsupportedFormatError, "this libvips build has no GIF loader" unless VipsGlue.type_find?("gifload")
318
- when "jxl"
319
- raise UnsupportedFormatError, "this libvips build has no JPEG XL loader" unless VipsGlue.type_find?("jxlload")
320
- end
321
-
322
- image = track.call(
323
- VipsGlue.operation(LOADERS.fetch(format), { filename: path, access: "sequential", fail_on: "error" })
324
- )
325
- [image, format]
326
- end
327
-
328
- def check_pixels!(image, max_pixels)
329
- if max_pixels.nil?
330
- limit = DEFAULT_MAX_PIXELS
331
- else
332
- limit = Integer(max_pixels)
333
- raise ArgumentError, "max_pixels must be positive" if limit <= 0
334
- end
335
- width = VipsGlue.width(image)
336
- height = VipsGlue.height(image)
337
- raise InvalidImageError, "image dimensions are invalid" if width <= 0 || height <= 0
338
- pixels = width * height
339
- raise LimitError, "image has #{pixels} pixels, exceeds #{limit}" if pixels > limit
340
- end
178
+ def checked_max_pixels(max_pixels)
179
+ return nil if max_pixels.nil?
341
180
 
342
- # libvips renamed the strip-metadata save option from "strip" to "keep"
343
- # (VIPS_FOREIGN_KEEP_NONE = 0) in 8.15; pick the spelling at runtime so
344
- # one gem build serves distro packages from 8.13 up.
345
- def strip_args
346
- @strip_args ||= (VipsGlue.version <=> [8, 15]) >= 0 ? { keep: 0 } : { strip: true }
347
- end
181
+ max_pixels = Integer(max_pixels)
182
+ raise ArgumentError, "max_pixels must be positive" if max_pixels <= 0
348
183
 
349
- def save_image(image, path, format, quality)
350
- case format
351
- when "jpg"
352
- VipsGlue.operation("jpegsave", { in: image, filename: path, Q: quality, interlace: false, **strip_args }, output: nil)
353
- when "png"
354
- VipsGlue.operation("pngsave", { in: image, filename: path, compression: 6, **strip_args }, output: nil)
355
- when "webp"
356
- VipsGlue.operation("webpsave", { in: image, filename: path, Q: quality, **strip_args }, output: nil)
357
- when "avif"
358
- VipsGlue.operation("heifsave", { in: image, filename: path, Q: quality, compression: "av1", **strip_args }, output: nil)
359
- when "gif"
360
- # cgif-backed saver; optional at libvips build time. GIF output is
361
- # palette-quantised and has no quality parameter.
362
- raise UnsupportedFormatError, "this libvips build cannot save GIF (cgif support missing)" unless VipsGlue.type_find?("gifsave")
363
- VipsGlue.operation("gifsave", { in: image, filename: path, **strip_args }, output: nil)
364
- when "jxl"
365
- raise UnsupportedFormatError, "this libvips build cannot save JPEG XL" unless VipsGlue.type_find?("jxlsave")
366
- VipsGlue.operation("jxlsave", { in: image, filename: path, Q: quality, **strip_args }, output: nil)
367
- else
368
- raise UnsupportedFormatError, "unsupported output format"
369
- end
184
+ max_pixels
370
185
  end
371
186
  end
372
187
  end