Module: SafeImage::Native

Defined in:
lib/safe_image/native.rb

Overview

The libvips fast path, implemented in pure Ruby on top of the VipsGlue Fiddle binding (formerly a compiled C extension; the function surface and messages are unchanged). Loaders are explicit per extension, every decode enforces the pixel cap from the header before pixel data is touched, and all images are released deterministically.

Constant Summary collapse

LOADERS =
{
  "jpg" => "jpegload",
  "png" => "pngload",
  "webp" => "webpload",
  "gif" => "gifload",
  "heic" => "heifload",
  "avif" => "heifload",
  "jxl" => "jxlload"
}.freeze

Class Method Summary collapse

Class Method Details

.convert(input, output, format, quality, max_pixels) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/safe_image/native.rb', line 112

def convert(input, output, format, quality, max_pixels)
  started = monotime
  quality = Integer(quality)
  validate_quality!(quality)
  out_format = output_format!(format)

  VipsGlue.with_images do |track|
    image, input_format = load_image(track, String(input))
    check_pixels!(image, max_pixels)
    rotated = track.call(VipsGlue.operation("autorot", { in: image }))

    # JPEG has no alpha; flatten onto white to match the ImageMagick
    # convert path (libvips composites onto black otherwise).
    final =
      if out_format == "jpg" && VipsGlue.alpha?(rotated)
        track.call(VipsGlue.operation("flatten", { in: rotated, background: [255.0, 255.0, 255.0] }))
      else
        rotated
      end
    save_image(final, String(output), out_format, quality)
    info(input_format, out_format, final, started)
  end
end

.crop_north(input, output, width, height, format, quality, max_pixels) ⇒ Object

Raises:

  • (ArgumentError)


87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/safe_image/native.rb', line 87

def crop_north(input, output, width, height, format, quality, max_pixels)
  started = monotime
  width = Integer(width)
  height = Integer(height)
  quality = Integer(quality)
  raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
  validate_quality!(quality)
  out_format = output_format!(format)

  VipsGlue.with_images do |track|
    image, input_format = load_image(track, String(input))
    check_pixels!(image, max_pixels)
    rotated = track.call(VipsGlue.operation("autorot", { in: image }))

    scale = [width.fdiv(VipsGlue.width(rotated)), height.fdiv(VipsGlue.height(rotated))].max * 1.0000001
    resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
    left = [(VipsGlue.width(resized) - width) / 2, 0].max
    cropped = track.call(
      VipsGlue.operation("extract_area", { input: resized, left: left, top: 0, width: width, height: height })
    )
    save_image(cropped, String(output), out_format, quality)
    info(input_format, out_format, cropped, started)
  end
end

.dominant_color(path, max_pixels) ⇒ Object

Alpha-weighted average colour as [r, g, b] integers. Premultiplying keeps parity with ImageMagick’s resize-based average; per-band means come from vips_stats (row b+1, column 4 of the stats matrix).



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
165
166
167
168
169
170
# File 'lib/safe_image/native.rb', line 139

def dominant_color(path, max_pixels)
  VipsGlue.with_images do |track|
    image, = load_image(track, String(path))
    check_pixels!(image, max_pixels)

    srgb =
      if VipsGlue.colourspace_supported?(image)
        track.call(VipsGlue.operation("colourspace", { in: image, space: "srgb" }))
      else
        image
      end
    has_alpha = VipsGlue.alpha?(srgb)
    work = has_alpha ? track.call(VipsGlue.operation("premultiply", { in: srgb })) : srgb

    stats = track.call(VipsGlue.operation("stats", { in: work }))
    columns = VipsGlue.width(stats)
    matrix = VipsGlue.image_bytes(stats).unpack("d*")
    mean = ->(band) { matrix[(band + 1) * columns + 4] || 0.0 }

    bands = VipsGlue.bands(work)
    colour_bands = has_alpha ? bands - 1 : bands
    colour_bands = colour_bands.clamp(1, 3)
    raise InvalidImageError, "image has no colour bands" if colour_bands < 1

    alpha_mean = has_alpha ? mean.call(bands - 1) : 255.0
    (0...3).map do |band|
      value = mean.call([band, colour_bands - 1].min)
      value = alpha_mean.positive? ? value * 255.0 / alpha_mean : 0.0 if has_alpha
      value.round.clamp(0, 255)
    end
  end
end

.letter_avatar(output, size, red, green, blue, markup, font, fontfile) ⇒ Object

Renders a letter avatar: a Pango glyph mask blended in white at 80% opacity over a solid background via a single linear transform. The markup string is escaped by the Ruby caller; font and fontfile come from an allowlist.

Raises:

  • (ArgumentError)


211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/safe_image/native.rb', line 211

def letter_avatar(output, size, red, green, blue, markup, font, fontfile)
  size = Integer(size)
  markup = String(markup)
  font = String(font)
  fontfile = String(fontfile)
  channels = [Integer(red), Integer(green), Integer(blue)]
  raise ArgumentError, "size must be 1..4096" unless (1..4096).cover?(size)
  unless channels.all? { |value| (0..255).cover?(value) }
    raise ArgumentError, "background channels must be 0..255"
  end
  unless VipsGlue.type_find?("text")
    raise UnsupportedFormatError, "this libvips build has no text renderer (Pango support missing)"
  end

  VipsGlue.with_images do |track|
    mask =
      if markup.empty?
        # Blank letter: solid background only.
        track.call(VipsGlue.operation("black", { width: size, height: size }))
      else
        text_inputs = { text: markup, font: font, dpi: 72 }
        text_inputs[:fontfile] = fontfile unless fontfile.empty?
        text = track.call(VipsGlue.operation("text", text_inputs))

        # vips_text returns the tight ink box; crop to the canvas when
        # the pointsize overflows it, then centre the ink optically.
        text_w = VipsGlue.width(text)
        text_h = VipsGlue.height(text)
        if text_w > size || text_h > size
          crop_w = [text_w, size].min
          crop_h = [text_h, size].min
          text = track.call(
            VipsGlue.operation(
              "extract_area",
              { input: text, left: (text_w - crop_w) / 2, top: (text_h - crop_h) / 2,
                width: crop_w, height: crop_h }
            )
          )
          text_w = crop_w
          text_h = crop_h
        end
        track.call(
          VipsGlue.operation(
            "embed",
            { in: text, x: (size - text_w) / 2, y: (size - text_h) / 2, width: size, height: size }
          )
        )
      end

    # blend = bg + (white - bg) * 0.8 * mask/255, one linear op.
    opacity = 204.0 / 255.0 # FFFFFFCC
    a = channels.map { |value| (255.0 - value) * opacity / 255.0 }
    blended = track.call(VipsGlue.operation("linear", { in: mask, a: a, b: channels.map(&:to_f) }))
    cast = track.call(VipsGlue.operation("cast", { in: blended, format: "uchar" }))
    srgb = track.call(VipsGlue.operation("copy", { in: cast, interpretation: "srgb" }))
    save_image(srgb, String(output), "png", 100)
  end
  true
end

.orientation(path, max_pixels) ⇒ Object



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

def orientation(path, max_pixels)
  VipsGlue.with_images do |track|
    image, = load_image(track, String(path))
    check_pixels!(image, max_pixels)
    value = VipsGlue.orientation(image)
    (1..8).cover?(value) ? value : 1
  end
end

.pages(path, max_pixels) ⇒ Object



172
173
174
175
176
177
178
# File 'lib/safe_image/native.rb', line 172

def pages(path, max_pixels)
  VipsGlue.with_images do |track|
    image, = load_image(track, String(path))
    check_pixels!(image, max_pixels)
    VipsGlue.pages(image)
  end
end

.png_from_rgba(bytes, width, height, output) ⇒ Object

Encodes a raw RGBA buffer (top-down rows) as PNG. Used by the pure-Ruby ICO decoder.

Raises:

  • (ArgumentError)


191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/safe_image/native.rb', line 191

def png_from_rgba(bytes, width, height, output)
  bytes = String(bytes)
  width = Integer(width)
  height = Integer(height)
  raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
  raise LimitError, "rgba buffer dimensions exceed 4096x4096" if width > 4096 || height > 4096
  raise ArgumentError, "rgba buffer must be width*height*4 bytes" if bytes.bytesize != width * height * 4

  VipsGlue.with_images do |track|
    image = track.call(VipsGlue.image_from_memory(bytes, width, height, 4, 0)) # 0 = uchar
    srgb = track.call(VipsGlue.operation("copy", { in: image, interpretation: "srgb" }))
    save_image(srgb, String(output), "png", 100)
  end
  true
end

.probe(path) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/safe_image/native.rb', line 23

def probe(path)
  started = monotime
  VipsGlue.with_images do |track|
    image, format = load_image(track, String(path))
    {
      format: format,
      width: VipsGlue.width(image),
      height: VipsGlue.height(image),
      duration_ms: monotime - started
    }
  end
end

.resize(input, output, scale, format, quality, max_pixels) ⇒ Object



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/safe_image/native.rb', line 67

def resize(input, output, scale, format, quality, max_pixels)
  started = monotime
  scale = Float(scale)
  quality = Integer(quality)
  unless scale.finite? && scale.positive? && scale <= 100.0
    raise ArgumentError, "scale must be finite and in 0..100"
  end
  validate_quality!(quality)
  out_format = output_format!(format)

  VipsGlue.with_images do |track|
    image, input_format = load_image(track, String(input))
    check_pixels!(image, max_pixels)
    rotated = track.call(VipsGlue.operation("autorot", { in: image }))
    resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
    save_image(resized, String(output), out_format, quality)
    info(input_format, out_format, resized, started)
  end
end

.thumbnail(input, output, width, height, format, quality, max_pixels) ⇒ Object

Raises:

  • (ArgumentError)


36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/safe_image/native.rb', line 36

def thumbnail(input, output, width, height, format, quality, max_pixels)
  started = monotime
  input = String(input)
  output = String(output)
  width = Integer(width)
  height = Integer(height)
  quality = Integer(quality)
  raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
  validate_quality!(quality)
  out_format = output_format!(format)

  VipsGlue.with_images do |track|
    # Header read through the explicit loader: validates the bytes and
    # enforces the pixel cap before any full decode.
    header, input_format = load_image(track, input)
    check_pixels!(header, max_pixels)

    # Thumbnail from the file so libvips can shrink on load (e.g.
    # libjpeg DCT downscaling); auto-rotates by default.
    thumb = track.call(
      VipsGlue.operation(
        "thumbnail",
        { filename: input, width: width, height: height,
          size: "both", crop: "centre", fail_on: "error" }
      )
    )
    save_image(thumb, output, out_format, quality)
    info(input_format, out_format, thumb, started)
  end
end