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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -10
- data/README.md +176 -168
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +43 -41
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -281
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +250 -48
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +367 -97
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +69 -24
- data/lib/safe_image/sandbox.rb +44 -191
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +186 -55
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -294
- metadata +48 -26
- data/lib/safe_image/discourse_compat.rb +0 -452
- data/lib/safe_image/svg_sanitizer.rb +0 -102
- data/lib/safe_image/vips_glue.rb +0 -361
data/lib/safe_image/native.rb
CHANGED
|
@@ -1,193 +1,118 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "tempfile"
|
|
4
4
|
|
|
5
5
|
module SafeImage
|
|
6
|
-
# The libvips
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
208
|
-
#
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
157
|
+
def input_format!(path)
|
|
158
|
+
format = Formats.extension(path)
|
|
159
|
+
raise UnsupportedFormatError, "unsupported input format" unless Formats.native_input?(format)
|
|
276
160
|
|
|
277
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
|
310
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|