safe_image 0.3.0 → 0.5.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -12
  3. data/README.md +140 -287
  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 +42 -40
  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 -290
  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 +225 -98
  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 +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  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 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -1,194 +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
32
 
47
- VipsGlue.with_images do |track|
48
- # Route through the explicit loader for the path's extension and keep
49
- # the resulting image object for the resize. Do not call the generic
50
- # filename-based `thumbnail` operation here: it re-sniffs the path and
51
- # can observe different bytes if a hostile local path is replaced
52
- # between the header check and the decode.
53
- image, input_format = load_image(track, input, autorotate: true)
54
- check_pixels!(image, max_pixels)
55
-
56
- thumb = track.call(
57
- VipsGlue.operation(
58
- "thumbnail_image",
59
- { in: image, width: width, height: height,
60
- size: "both", crop: "centre", fail_on: "error" }
61
- )
62
- )
63
- save_image(thumb, output, out_format, quality)
64
- info(input_format, out_format, thumb, started)
65
- 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
+ )
66
44
  end
67
45
 
68
46
  def resize(input, output, scale, format, quality, max_pixels)
69
- started = monotime
70
47
  scale = Float(scale)
71
48
  quality = Integer(quality)
72
49
  unless scale.finite? && scale.positive? && scale <= 100.0
73
50
  raise ArgumentError, "scale must be finite and in 0..100"
74
51
  end
75
52
  validate_quality!(quality)
76
- out_format = output_format!(format)
77
53
 
78
- VipsGlue.with_images do |track|
79
- image, input_format = load_image(track, String(input), autorotate: true)
80
- check_pixels!(image, max_pixels)
81
- rotated = track.call(VipsGlue.operation("autorot", { in: image }))
82
- resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
83
- save_image(resized, String(output), out_format, quality)
84
- info(input_format, out_format, resized, started)
85
- 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
+ )
86
64
  end
87
65
 
88
66
  def crop_north(input, output, width, height, format, quality, max_pixels)
89
- started = monotime
90
67
  width = Integer(width)
91
68
  height = Integer(height)
92
69
  quality = Integer(quality)
93
70
  raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
94
71
  validate_quality!(quality)
95
- out_format = output_format!(format)
96
-
97
- VipsGlue.with_images do |track|
98
- image, input_format = load_image(track, String(input), autorotate: true)
99
- check_pixels!(image, max_pixels)
100
- rotated = track.call(VipsGlue.operation("autorot", { in: image }))
101
72
 
102
- scale = [width.fdiv(VipsGlue.width(rotated)), height.fdiv(VipsGlue.height(rotated))].max * 1.0000001
103
- resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
104
- left = [(VipsGlue.width(resized) - width) / 2, 0].max
105
- cropped = track.call(
106
- VipsGlue.operation("extract_area", { input: resized, left: left, top: 0, width: width, height: height })
107
- )
108
- save_image(cropped, String(output), out_format, quality)
109
- info(input_format, out_format, cropped, started)
110
- 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
+ )
111
84
  end
112
85
 
113
86
  def convert(input, output, format, quality, max_pixels)
114
- started = monotime
115
87
  quality = Integer(quality)
116
88
  validate_quality!(quality)
117
- out_format = output_format!(format)
118
-
119
- VipsGlue.with_images do |track|
120
- image, input_format = load_image(track, String(input), autorotate: true)
121
- check_pixels!(image, max_pixels)
122
- rotated = track.call(VipsGlue.operation("autorot", { in: image }))
123
-
124
- # JPEG has no alpha; flatten onto white to match the ImageMagick
125
- # convert path (libvips composites onto black otherwise).
126
- final =
127
- if out_format == "jpg" && VipsGlue.alpha?(rotated)
128
- track.call(VipsGlue.operation("flatten", { in: rotated, background: [255.0, 255.0, 255.0] }))
129
- else
130
- rotated
131
- end
132
- save_image(final, String(output), out_format, quality)
133
- info(input_format, out_format, final, started)
134
- end
89
+ input = String(input)
90
+ input_format!(input)
91
+ NativeHelper.convert(input, String(output), output_format!(format), quality, checked_max_pixels(max_pixels))
135
92
  end
136
93
 
137
- # Alpha-weighted average colour as [r, g, b] integers. Premultiplying
138
- # keeps parity with ImageMagick's resize-based average; per-band means
139
- # come from vips_stats (row b+1, column 4 of the stats matrix).
140
94
  def dominant_color(path, max_pixels)
141
- VipsGlue.with_images do |track|
142
- image, = load_image(track, String(path))
143
- check_pixels!(image, max_pixels)
144
-
145
- srgb =
146
- if VipsGlue.colourspace_supported?(image)
147
- track.call(VipsGlue.operation("colourspace", { in: image, space: "srgb" }))
148
- else
149
- image
150
- end
151
- has_alpha = VipsGlue.alpha?(srgb)
152
- work = has_alpha ? track.call(VipsGlue.operation("premultiply", { in: srgb })) : srgb
153
-
154
- stats = track.call(VipsGlue.operation("stats", { in: work }))
155
- columns = VipsGlue.width(stats)
156
- matrix = VipsGlue.image_bytes(stats).unpack("d*")
157
- mean = ->(band) { matrix[(band + 1) * columns + 4] || 0.0 }
158
-
159
- bands = VipsGlue.bands(work)
160
- colour_bands = has_alpha ? bands - 1 : bands
161
- colour_bands = colour_bands.clamp(1, 3)
162
- raise InvalidImageError, "image has no colour bands" if colour_bands < 1
163
-
164
- alpha_mean = has_alpha ? mean.call(bands - 1) : 255.0
165
- (0...3).map do |band|
166
- value = mean.call([band, colour_bands - 1].min)
167
- value = alpha_mean.positive? ? value * 255.0 / alpha_mean : 0.0 if has_alpha
168
- value.round.clamp(0, 255)
169
- end
170
- 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) }
171
99
  end
172
100
 
173
101
  def pages(path, max_pixels)
174
- VipsGlue.with_images do |track|
175
- image, = load_image(track, String(path))
176
- check_pixels!(image, max_pixels)
177
- VipsGlue.pages(image)
178
- end
102
+ path = String(path)
103
+ input_format!(path)
104
+ NativeHelper.pages(path, checked_max_pixels(max_pixels))
179
105
  end
180
106
 
181
107
  def orientation(path, max_pixels)
182
- VipsGlue.with_images do |track|
183
- image, = load_image(track, String(path))
184
- check_pixels!(image, max_pixels)
185
- value = VipsGlue.orientation(image)
186
- (1..8).cover?(value) ? value : 1
187
- end
108
+ path = String(path)
109
+ input_format!(path)
110
+ NativeHelper.orientation(path, checked_max_pixels(max_pixels))
188
111
  end
189
112
 
190
- # Encodes a raw RGBA buffer (top-down rows) as PNG. Used by the
191
- # 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.
192
116
  def png_from_rgba(bytes, width, height, output)
193
117
  bytes = String(bytes)
194
118
  width = Integer(width)
@@ -197,185 +121,67 @@ module SafeImage
197
121
  raise LimitError, "rgba buffer dimensions exceed 4096x4096" if width > 4096 || height > 4096
198
122
  raise ArgumentError, "rgba buffer must be width*height*4 bytes" if bytes.bytesize != width * height * 4
199
123
 
200
- VipsGlue.with_images do |track|
201
- image = track.call(VipsGlue.image_from_memory(bytes, width, height, 4, 0)) # 0 = uchar
202
- srgb = track.call(VipsGlue.operation("copy", { in: image, interpretation: "srgb" }))
203
- 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))
204
128
  end
205
129
  true
206
130
  end
207
131
 
208
- # Renders a letter avatar: a Pango glyph mask blended in white at 80%
209
- # opacity over a solid background via a single linear transform. The
210
- # markup string is escaped by the Ruby caller; font and fontfile come
211
- # 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.
212
134
  def letter_avatar(output, size, red, green, blue, markup, font, fontfile)
213
135
  size = Integer(size)
214
- markup = String(markup)
215
- font = String(font)
216
- fontfile = String(fontfile)
217
136
  channels = [Integer(red), Integer(green), Integer(blue)]
218
137
  raise ArgumentError, "size must be 1..4096" unless (1..4096).cover?(size)
219
138
  unless channels.all? { |value| (0..255).cover?(value) }
220
139
  raise ArgumentError, "background channels must be 0..255"
221
140
  end
222
- unless VipsGlue.type_find?("text")
223
- raise UnsupportedFormatError, "this libvips build has no text renderer (Pango support missing)"
224
- end
225
-
226
- VipsGlue.with_images do |track|
227
- mask =
228
- if markup.empty?
229
- # Blank letter: solid background only.
230
- track.call(VipsGlue.operation("black", { width: size, height: size }))
231
- else
232
- text_inputs = { text: markup, font: font, dpi: 72 }
233
- text_inputs[:fontfile] = fontfile unless fontfile.empty?
234
- text = track.call(VipsGlue.operation("text", text_inputs))
235
141
 
236
- # vips_text returns the tight ink box; crop to the canvas when
237
- # the pointsize overflows it, then centre the ink optically.
238
- text_w = VipsGlue.width(text)
239
- text_h = VipsGlue.height(text)
240
- if text_w > size || text_h > size
241
- crop_w = [text_w, size].min
242
- crop_h = [text_h, size].min
243
- text = track.call(
244
- VipsGlue.operation(
245
- "extract_area",
246
- { input: text, left: (text_w - crop_w) / 2, top: (text_h - crop_h) / 2,
247
- width: crop_w, height: crop_h }
248
- )
249
- )
250
- text_w = crop_w
251
- text_h = crop_h
252
- end
253
- track.call(
254
- VipsGlue.operation(
255
- "embed",
256
- { in: text, x: (size - text_w) / 2, y: (size - text_h) / 2, width: size, height: size }
257
- )
258
- )
259
- end
260
-
261
- # blend = bg + (white - bg) * 0.8 * mask/255, one linear op.
262
- opacity = 204.0 / 255.0 # FFFFFFCC
263
- a = channels.map { |value| (255.0 - value) * opacity / 255.0 }
264
- blended = track.call(VipsGlue.operation("linear", { in: mask, a: a, b: channels.map(&:to_f) }))
265
- cast = track.call(VipsGlue.operation("cast", { in: blended, format: "uchar" }))
266
- srgb = track.call(VipsGlue.operation("copy", { in: cast, interpretation: "srgb" }))
267
- save_image(srgb, String(output), "png", 100)
268
- 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
+ )
269
152
  true
270
153
  end
271
154
 
272
155
  private
273
156
 
274
- def monotime
275
- Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
276
- end
157
+ def input_format!(path)
158
+ format = Formats.extension(path)
159
+ raise UnsupportedFormatError, "unsupported input format" unless Formats.native_input?(format)
277
160
 
278
- def info(input_format, output_format, image, started)
279
- {
280
- input_format: input_format,
281
- output_format: output_format,
282
- width: VipsGlue.width(image),
283
- height: VipsGlue.height(image),
284
- duration_ms: monotime - started
285
- }
161
+ format
286
162
  end
287
163
 
288
- def normalized_format(ext)
289
- case ext.to_s.downcase
290
- when "jpg", "jpeg" then "jpg"
291
- when "png" then "png"
292
- when "webp" then "webp"
293
- when "gif" then "gif"
294
- when "heic", "heif" then "heic"
295
- when "avif" then "avif"
296
- 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"
297
169
  end
298
- end
299
170
 
300
- def output_format!(format)
301
- normalized = normalized_format(String(format))
302
- raise UnsupportedFormatError, "unsupported output format" if normalized.nil? || normalized == "heic"
303
- normalized
171
+ canonical
304
172
  end
305
173
 
306
174
  def validate_quality!(quality)
307
175
  raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
308
176
  end
309
177
 
310
- def load_image(track, path, autorotate: false)
311
- format = normalized_format(File.extname(path).delete_prefix("."))
312
- raise UnsupportedFormatError, "unsupported input format" unless format
178
+ def checked_max_pixels(max_pixels)
179
+ return nil if max_pixels.nil?
313
180
 
314
- case format
315
- when "gif"
316
- # libnsgif loader: first frame only (the n=1 default), matching the
317
- # [0] semantics of the ImageMagick compatibility backend.
318
- raise UnsupportedFormatError, "this libvips build has no GIF loader" unless VipsGlue.type_find?("gifload")
319
- when "jxl"
320
- raise UnsupportedFormatError, "this libvips build has no JPEG XL loader" unless VipsGlue.type_find?("jxlload")
321
- end
322
-
323
- options = { filename: path, access: "sequential", fail_on: "error" }
324
- image = track.call(VipsGlue.operation(LOADERS.fetch(format), options))
325
-
326
- # autorot flips/rotates pull rows out of input order, which a
327
- # sequential source can only serve while the image fits its readahead
328
- # window (~512px); larger oriented images fail with "out of order
329
- # read". Reload those with random access — the open itself is
330
- # header-only, so the caller's pixel cap still runs before any decode.
331
- if autorotate && VipsGlue.orientation(image) > 1
332
- image = track.call(VipsGlue.operation(LOADERS.fetch(format), options.merge(access: "random")))
333
- end
334
- [image, format]
335
- end
336
-
337
- def check_pixels!(image, max_pixels)
338
- if max_pixels.nil?
339
- limit = DEFAULT_MAX_PIXELS
340
- else
341
- limit = Integer(max_pixels)
342
- raise ArgumentError, "max_pixels must be positive" if limit <= 0
343
- end
344
- width = VipsGlue.width(image)
345
- height = VipsGlue.height(image)
346
- raise InvalidImageError, "image dimensions are invalid" if width <= 0 || height <= 0
347
- pixels = width * height
348
- raise LimitError, "image has #{pixels} pixels, exceeds #{limit}" if pixels > limit
349
- end
350
-
351
- # libvips renamed the strip-metadata save option from "strip" to "keep"
352
- # (VIPS_FOREIGN_KEEP_NONE = 0) in 8.15; pick the spelling at runtime so
353
- # one gem build serves distro packages from 8.13 up.
354
- def strip_args
355
- @strip_args ||= (VipsGlue.version <=> [8, 15]) >= 0 ? { keep: 0 } : { strip: true }
356
- end
181
+ max_pixels = Integer(max_pixels)
182
+ raise ArgumentError, "max_pixels must be positive" if max_pixels <= 0
357
183
 
358
- def save_image(image, path, format, quality)
359
- case format
360
- when "jpg"
361
- VipsGlue.operation("jpegsave", { in: image, filename: path, Q: quality, interlace: false, **strip_args }, output: nil)
362
- when "png"
363
- VipsGlue.operation("pngsave", { in: image, filename: path, compression: 6, **strip_args }, output: nil)
364
- when "webp"
365
- VipsGlue.operation("webpsave", { in: image, filename: path, Q: quality, **strip_args }, output: nil)
366
- when "avif"
367
- VipsGlue.operation("heifsave", { in: image, filename: path, Q: quality, compression: "av1", **strip_args }, output: nil)
368
- when "gif"
369
- # cgif-backed saver; optional at libvips build time. GIF output is
370
- # palette-quantised and has no quality parameter.
371
- raise UnsupportedFormatError, "this libvips build cannot save GIF (cgif support missing)" unless VipsGlue.type_find?("gifsave")
372
- VipsGlue.operation("gifsave", { in: image, filename: path, **strip_args }, output: nil)
373
- when "jxl"
374
- raise UnsupportedFormatError, "this libvips build cannot save JPEG XL" unless VipsGlue.type_find?("jxlsave")
375
- VipsGlue.operation("jxlsave", { in: image, filename: path, Q: quality, **strip_args }, output: nil)
376
- else
377
- raise UnsupportedFormatError, "unsupported output format"
378
- end
184
+ max_pixels
379
185
  end
380
186
  end
381
187
  end