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
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
|
-
require "rexml/document"
|
|
5
|
-
require "rexml/parsers/pullparser"
|
|
6
4
|
|
|
7
5
|
module SafeImage
|
|
8
6
|
module SvgMetadata
|
|
@@ -18,6 +16,36 @@ module SafeImage
|
|
|
18
16
|
LENGTH_PATTERN = /\A\s*([+]?(?:\d+(?:\.\d+)?|\.\d+))(?:px)?\s*\z/i.freeze
|
|
19
17
|
VIEWBOX_SPLIT = /[\s,]+/.freeze
|
|
20
18
|
|
|
19
|
+
# Byte-order marks for the multi-byte encodings whose ASCII characters our
|
|
20
|
+
# byte-level scans below cannot see through. XML mandates a BOM for UTF-16
|
|
21
|
+
# and UTF-32, so a document in one of these encodings either carries a BOM
|
|
22
|
+
# here or contains NUL bytes for its ASCII characters (caught separately).
|
|
23
|
+
# Order matters: the UTF-32 LE mark begins with the UTF-16 LE mark.
|
|
24
|
+
NON_UTF8_BOMS = [
|
|
25
|
+
"\xFF\xFE\x00\x00".b, # UTF-32 LE
|
|
26
|
+
"\x00\x00\xFE\xFF".b, # UTF-32 BE
|
|
27
|
+
"\xFF\xFE".b, # UTF-16 LE
|
|
28
|
+
"\xFE\xFF".b # UTF-16 BE
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
UTF8_BOM = "\xEF\xBB\xBF".b.freeze
|
|
32
|
+
# Declared encodings we accept: UTF-8/ASCII plus the single-byte,
|
|
33
|
+
# ASCII-transparent legacy charsets (ISO-8859-*, Windows-125x). Their bytes
|
|
34
|
+
# below 0x80 decode to identical ASCII, so the byte scans below see the same
|
|
35
|
+
# markup any XML decoder or browser does; and being single-byte, no
|
|
36
|
+
# lead byte can swallow a following quote the way Shift-JIS, GBK, or Big5
|
|
37
|
+
# can. Multi-byte (Shift-JIS, GBK, EUC-*, ISO-2022-*), transforming (UTF-7:
|
|
38
|
+
# "+ADw-" decodes to "<"), and NUL-interleaved (UTF-16/32) encodings are
|
|
39
|
+
# deliberately excluded — they let bytes our ASCII scans cannot see become
|
|
40
|
+
# markup the parser acts on. The shape match alone is not airtight:
|
|
41
|
+
# "utf8" or "windows-1259" fit the pattern yet name no real encoding, so a
|
|
42
|
+
# name must also resolve via Encoding.find to pass — lookalikes fail
|
|
43
|
+
# closed here instead of leaking a parser encoding error to the caller.
|
|
44
|
+
SAFE_DECLARED_ENCODING = /\A(?:utf-?8|us-ascii|ascii|iso-?8859-?\d{1,2}|(?:windows|cp)-?125\d)\z/i.freeze
|
|
45
|
+
# ASCII-only so it matches the binary buffer; the optional BOM is stripped
|
|
46
|
+
# before matching rather than embedded here (which would make this UTF-8).
|
|
47
|
+
XML_DECL_ENCODING = /\A\s*<\?xml\b[^>]*?\bencoding\s*=\s*["']([^"']+)["']/i.freeze
|
|
48
|
+
|
|
21
49
|
def probe(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES)
|
|
22
50
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
51
|
path = safe_svg_path(path)
|
|
@@ -34,6 +62,14 @@ module SafeImage
|
|
|
34
62
|
def dimensions(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES)
|
|
35
63
|
xml = read_svg(path, max_bytes: max_bytes)
|
|
36
64
|
_name, attributes = scan_svg!(xml)
|
|
65
|
+
dimensions_from_attributes(attributes, max_pixels: max_pixels)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Computes and validates the document dimensions from the already-scanned
|
|
69
|
+
# root attributes, so a caller that has run scan_svg! does not re-read or
|
|
70
|
+
# re-scan the file. Same width/height-then-viewBox fallback and limits as
|
|
71
|
+
# dimensions above.
|
|
72
|
+
def dimensions_from_attributes(attributes, max_pixels: nil)
|
|
37
73
|
width = parse_length(attributes["width"])
|
|
38
74
|
height = parse_length(attributes["height"])
|
|
39
75
|
|
|
@@ -46,27 +82,12 @@ module SafeImage
|
|
|
46
82
|
validate_dimensions!(width, height, max_pixels: max_pixels)
|
|
47
83
|
end
|
|
48
84
|
|
|
49
|
-
# Builds the full REXML tree. Used only by the SVG sanitizer, which needs to
|
|
50
|
-
# walk and rewrite the document; metadata reads go through the DOM-free
|
|
51
|
-
# streaming path above. The streaming validation runs first so a document
|
|
52
|
-
# that breaches the structural caps is rejected before the tree is built.
|
|
53
|
-
def parse(path, max_bytes: MAX_SVG_BYTES)
|
|
54
|
-
xml = read_svg(path, max_bytes: max_bytes)
|
|
55
|
-
scan_svg!(xml)
|
|
56
|
-
doc = REXML::Document.new(xml)
|
|
57
|
-
raise InvalidImageError, "SVG root required" unless doc.root&.name == "svg"
|
|
58
|
-
|
|
59
|
-
doc
|
|
60
|
-
rescue REXML::ParseException => e
|
|
61
|
-
raise InvalidImageError, "invalid SVG: #{e.message}"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
85
|
def read_svg(path, max_bytes: MAX_SVG_BYTES)
|
|
65
86
|
path = safe_svg_path(path)
|
|
66
87
|
size = File.size(path)
|
|
67
88
|
raise LimitError, "SVG exceeds #{max_bytes} bytes" if size > max_bytes
|
|
68
89
|
|
|
69
|
-
xml = File.binread(path, max_bytes + 1)
|
|
90
|
+
xml = File.binread(path, max_bytes + 1) || "".b
|
|
70
91
|
raise LimitError, "SVG exceeds #{max_bytes} bytes" if xml.bytesize > max_bytes
|
|
71
92
|
reject_unsafe_xml!(xml)
|
|
72
93
|
xml
|
|
@@ -79,10 +100,41 @@ module SafeImage
|
|
|
79
100
|
end
|
|
80
101
|
|
|
81
102
|
def reject_unsafe_xml!(xml)
|
|
103
|
+
# The DOCTYPE/PI scans below are ASCII byte regexes; they only see what
|
|
104
|
+
# they expect when the bytes we scan decode to the same markup the XML
|
|
105
|
+
# parser sees. That holds for UTF-8 and single-byte ASCII-transparent
|
|
106
|
+
# charsets but not for UTF-16/32 or multi-byte/transforming encodings, so
|
|
107
|
+
# reject those first.
|
|
108
|
+
reject_unsafe_encoding!(xml)
|
|
82
109
|
raise InvalidImageError, "doctype is not allowed in SVG" if xml.match?(/<!DOCTYPE/i)
|
|
83
110
|
raise InvalidImageError, "XML processing instructions are not allowed in SVG" if xml.match?(/<\?(?!xml\s)/i)
|
|
84
111
|
end
|
|
85
112
|
|
|
113
|
+
def reject_unsafe_encoding!(xml)
|
|
114
|
+
bytes = xml.b
|
|
115
|
+
# UTF-16/UTF-32 interleave NUL bytes between ASCII characters, hiding
|
|
116
|
+
# "<!DOCTYPE" from the ASCII scans while the XML parser still decodes and
|
|
117
|
+
# honours it. (NUL is invalid in XML 1.0 regardless, so this also rejects
|
|
118
|
+
# garbage.)
|
|
119
|
+
if NON_UTF8_BOMS.any? { |bom| bytes.start_with?(bom) } || bytes.include?("\x00".b)
|
|
120
|
+
raise InvalidImageError, "SVG must use a single-byte or UTF-8 encoding"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
bytes = bytes.byteslice(UTF8_BOM.bytesize..) if bytes.start_with?(UTF8_BOM)
|
|
124
|
+
match = bytes.match(XML_DECL_ENCODING)
|
|
125
|
+
return unless match
|
|
126
|
+
return if match[1].match?(SAFE_DECLARED_ENCODING) && known_encoding?(match[1])
|
|
127
|
+
|
|
128
|
+
raise InvalidImageError, "unsupported SVG encoding: #{match[1]}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def known_encoding?(name)
|
|
132
|
+
Encoding.find(name)
|
|
133
|
+
true
|
|
134
|
+
rescue ArgumentError
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
86
138
|
def parse_length(value)
|
|
87
139
|
value = value.to_s
|
|
88
140
|
match = LENGTH_PATTERN.match(value)
|
|
@@ -110,53 +162,132 @@ module SafeImage
|
|
|
110
162
|
|
|
111
163
|
def validate_dimensions!(width, height, max_pixels: nil)
|
|
112
164
|
raise InvalidImageError, "SVG dimensions are missing or invalid" unless width&.positive? && height&.positive?
|
|
113
|
-
|
|
165
|
+
if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION
|
|
166
|
+
raise LimitError, "SVG dimensions exceed #{MAX_SVG_DIMENSION}px"
|
|
167
|
+
end
|
|
114
168
|
|
|
115
169
|
pixels = width * height
|
|
116
|
-
limit =
|
|
170
|
+
limit =
|
|
171
|
+
if max_pixels.nil?
|
|
172
|
+
MAX_SVG_PIXELS
|
|
173
|
+
else
|
|
174
|
+
value = Integer(max_pixels)
|
|
175
|
+
raise ArgumentError, "max_pixels must be positive" if value <= 0
|
|
176
|
+
|
|
177
|
+
value
|
|
178
|
+
end
|
|
117
179
|
raise LimitError, "SVG has #{pixels.to_i} pixels, exceeds #{limit}" if pixels > limit
|
|
118
180
|
|
|
119
181
|
[width.ceil, height.ceil]
|
|
120
182
|
end
|
|
121
183
|
|
|
122
|
-
# Streams the document with a
|
|
123
|
-
# events arrive, so a hostile "millions of tiny
|
|
124
|
-
# rejected at the cap without ever retaining the
|
|
125
|
-
#
|
|
126
|
-
# element's name and
|
|
184
|
+
# Streams the document with a SAX parser, enforcing the structural caps as
|
|
185
|
+
# events arrive (see cap_scanner_class), so a hostile "millions of tiny
|
|
186
|
+
# elements" document is rejected at the cap without ever retaining the
|
|
187
|
+
# multi-million-object DOM a parse-then-validate approach would build.
|
|
188
|
+
# Returns the root element's local name and a localname=>value hash of its
|
|
189
|
+
# attributes, matching the contract dimensions_from_attributes consumes.
|
|
190
|
+
#
|
|
191
|
+
# SAX does NOT raise on malformed XML even with recovery disabled — it
|
|
192
|
+
# reports through the error callback and keeps going — so well-formedness is
|
|
193
|
+
# enforced by recording any reported error and rejecting after the parse.
|
|
194
|
+
# This preserves the old pull-parser's reject set (unclosed/mismatched
|
|
195
|
+
# tags, trailing junk) and is strictly stricter on multiple root elements,
|
|
196
|
+
# which is a safe direction for a gate.
|
|
127
197
|
def scan_svg!(xml)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
attributes += event[1].size
|
|
145
|
-
raise LimitError, "SVG has too many attributes" if attributes > MAX_SVG_ATTRIBUTES
|
|
146
|
-
|
|
147
|
-
if root_name.nil?
|
|
148
|
-
root_name = event[0]
|
|
149
|
-
root_attributes = event[1]
|
|
150
|
-
end
|
|
151
|
-
elsif event.end_element?
|
|
152
|
-
depth -= 1
|
|
153
|
-
end
|
|
198
|
+
require_nokogiri
|
|
199
|
+
handler = cap_scanner_class.new
|
|
200
|
+
parser = Nokogiri::XML::SAX::Parser.new(handler)
|
|
201
|
+
begin
|
|
202
|
+
# recovery: false — do not silently repair malformed markup. Errors still
|
|
203
|
+
# arrive via the error callback rather than as exceptions, so they are
|
|
204
|
+
# checked explicitly below.
|
|
205
|
+
parser.parse(xml) { |ctx| ctx.recovery = false }
|
|
206
|
+
rescue LimitError, InvalidImageError
|
|
207
|
+
raise # our own cap/validation rejections, surfaced from a callback
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
# Nokogiri rejects some inputs by raising rather than via the error
|
|
210
|
+
# callback (e.g. empty input -> "input string cannot be empty"). Keep
|
|
211
|
+
# untrusted-input failures inside our error hierarchy.
|
|
212
|
+
raise InvalidImageError, "invalid SVG: #{e.message}"
|
|
154
213
|
end
|
|
155
214
|
|
|
156
|
-
raise InvalidImageError, "SVG
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
215
|
+
raise InvalidImageError, "invalid SVG: #{handler.parse_error}" if handler.parse_error
|
|
216
|
+
raise InvalidImageError, "SVG root required" unless handler.root_name == "svg"
|
|
217
|
+
|
|
218
|
+
[handler.root_name, handler.root_attributes]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Loaded on first SVG use, not at file load: keeping the XML library off the
|
|
222
|
+
# hot path of every non-SVG operation where it would otherwise be paid for
|
|
223
|
+
# nothing.
|
|
224
|
+
def require_nokogiri
|
|
225
|
+
require "nokogiri"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The SAX cap-enforcement handler, built lazily and memoised the first time
|
|
229
|
+
# an SVG is scanned. It subclasses Nokogiri::XML::SAX::Document, so it cannot
|
|
230
|
+
# be declared at file-load time without forcing nokogiri to load eagerly and
|
|
231
|
+
# defeating the lazy require above. A breached cap raises LimitError straight
|
|
232
|
+
# out of a callback; libxml2 propagates it at the next event boundary, so the
|
|
233
|
+
# parse aborts promptly rather than scanning to the end (verified: rejection
|
|
234
|
+
# time grows far slower than input size).
|
|
235
|
+
def cap_scanner_class
|
|
236
|
+
@cap_scanner_class ||=
|
|
237
|
+
Class.new(Nokogiri::XML::SAX::Document) do
|
|
238
|
+
attr_reader :root_name, :root_attributes, :parse_error
|
|
239
|
+
|
|
240
|
+
def initialize
|
|
241
|
+
super
|
|
242
|
+
@depth = -1
|
|
243
|
+
@elements = 0
|
|
244
|
+
@attributes = 0
|
|
245
|
+
@root_name = nil
|
|
246
|
+
@root_attributes = nil
|
|
247
|
+
@parse_error = nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# attrs: array of Nokogiri::XML::SAX::Parser::Attribute (localname/value),
|
|
251
|
+
# NOT including namespace declarations; `ns` carries the xmlns decls. Both
|
|
252
|
+
# count toward the attribute cap so the bound cannot be sidestepped by
|
|
253
|
+
# spraying namespace declarations.
|
|
254
|
+
def start_element_namespace(name, attrs = [], _prefix = nil, _uri = nil, ns = [])
|
|
255
|
+
@depth += 1
|
|
256
|
+
raise LimitError, "SVG nesting exceeds #{MAX_SVG_DEPTH}" if @depth > MAX_SVG_DEPTH
|
|
257
|
+
|
|
258
|
+
@elements += 1
|
|
259
|
+
raise LimitError, "SVG has too many elements" if @elements > MAX_SVG_ELEMENTS
|
|
260
|
+
|
|
261
|
+
@attributes += attrs.length + ns.length
|
|
262
|
+
raise LimitError, "SVG has too many attributes" if @attributes > MAX_SVG_ATTRIBUTES
|
|
263
|
+
|
|
264
|
+
return unless @root_name.nil?
|
|
265
|
+
|
|
266
|
+
@root_name = name
|
|
267
|
+
@root_attributes =
|
|
268
|
+
attrs.each_with_object({}) do |attr, hash|
|
|
269
|
+
# Dimensions are security-relevant: only the actual no-namespace
|
|
270
|
+
# root attributes a browser will use may feed the pixel cap. A
|
|
271
|
+
# namespaced e:width/e:height must not shadow width/height here.
|
|
272
|
+
next unless attr.prefix.to_s.empty? && attr.uri.to_s.empty?
|
|
273
|
+
|
|
274
|
+
hash[attr.localname] = attr.value
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def end_element_namespace(_name, _prefix = nil, _uri = nil)
|
|
279
|
+
@depth -= 1
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# libxml2 reports well-formedness violations here rather than raising;
|
|
283
|
+
# record the first so scan_svg! can reject on it.
|
|
284
|
+
def error(message)
|
|
285
|
+
@parse_error ||= message.to_s.strip
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def warning(_message)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
160
291
|
end
|
|
161
292
|
end
|
|
162
293
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeImage
|
|
4
|
+
# Inline implementations for image-producing operations.
|
|
5
|
+
class TransformOperations < OperationSet
|
|
6
|
+
def thumbnail(
|
|
7
|
+
input:,
|
|
8
|
+
output:,
|
|
9
|
+
width:,
|
|
10
|
+
height:,
|
|
11
|
+
format: nil,
|
|
12
|
+
quality: QualityDefaults::JPEG,
|
|
13
|
+
max_pixels: nil,
|
|
14
|
+
optimize: false,
|
|
15
|
+
optimize_mode: :lossless,
|
|
16
|
+
chroma_subsampling: :auto
|
|
17
|
+
)
|
|
18
|
+
Processor.new(
|
|
19
|
+
max_pixels: resolved_max_pixels(max_pixels),
|
|
20
|
+
chroma_subsampling: chroma_subsampling,
|
|
21
|
+
config: config
|
|
22
|
+
).thumbnail(
|
|
23
|
+
input: input,
|
|
24
|
+
output: output,
|
|
25
|
+
width: width,
|
|
26
|
+
height: height,
|
|
27
|
+
format: format,
|
|
28
|
+
quality: quality,
|
|
29
|
+
optimize: optimize,
|
|
30
|
+
optimize_mode: optimize_mode
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def optimize(input:, output:, mode: :lossless, strip_metadata: true, quality: nil, strict: true)
|
|
35
|
+
Optimizer.optimize(
|
|
36
|
+
input: input,
|
|
37
|
+
output: output,
|
|
38
|
+
mode: mode,
|
|
39
|
+
strip_metadata: strip_metadata,
|
|
40
|
+
quality: quality,
|
|
41
|
+
strict: strict
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resize(
|
|
46
|
+
input:,
|
|
47
|
+
output:,
|
|
48
|
+
width:,
|
|
49
|
+
height:,
|
|
50
|
+
quality: nil,
|
|
51
|
+
optimize: true,
|
|
52
|
+
max_pixels: nil,
|
|
53
|
+
chroma_subsampling: :auto
|
|
54
|
+
)
|
|
55
|
+
backend.resize(
|
|
56
|
+
input: input,
|
|
57
|
+
output: output,
|
|
58
|
+
width: width,
|
|
59
|
+
height: height,
|
|
60
|
+
quality: quality,
|
|
61
|
+
optimize: optimize,
|
|
62
|
+
max_pixels: max_pixels,
|
|
63
|
+
chroma_subsampling: chroma_subsampling
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def crop(input:, output:, width:, height:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
|
|
68
|
+
backend.crop(
|
|
69
|
+
input: input,
|
|
70
|
+
output: output,
|
|
71
|
+
width: width,
|
|
72
|
+
height: height,
|
|
73
|
+
quality: quality,
|
|
74
|
+
optimize: optimize,
|
|
75
|
+
max_pixels: max_pixels,
|
|
76
|
+
chroma_subsampling: chroma_subsampling
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def downsize(
|
|
81
|
+
input:,
|
|
82
|
+
output:,
|
|
83
|
+
dimensions:,
|
|
84
|
+
optimize: true,
|
|
85
|
+
max_pixels: nil,
|
|
86
|
+
quality: QualityDefaults::JPEG,
|
|
87
|
+
chroma_subsampling: :auto
|
|
88
|
+
)
|
|
89
|
+
backend.downsize(
|
|
90
|
+
input: input,
|
|
91
|
+
output: output,
|
|
92
|
+
dimensions: dimensions,
|
|
93
|
+
optimize: optimize,
|
|
94
|
+
max_pixels: max_pixels,
|
|
95
|
+
quality: quality,
|
|
96
|
+
chroma_subsampling: chroma_subsampling
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def convert(input:, output:, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
|
|
101
|
+
backend.convert(
|
|
102
|
+
input: input,
|
|
103
|
+
output: output,
|
|
104
|
+
format: format,
|
|
105
|
+
quality: quality,
|
|
106
|
+
optimize: optimize,
|
|
107
|
+
max_pixels: max_pixels,
|
|
108
|
+
chroma_subsampling: chroma_subsampling
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fix_orientation(input:, output:, max_pixels: nil, quality: nil)
|
|
113
|
+
backend.fix_orientation(input: input, output: output, max_pixels: max_pixels, quality: quality)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def convert_favicon_to_png(input:, output:, optimize: true, max_pixels: nil)
|
|
117
|
+
backend.convert_favicon_to_png(input: input, output: output, optimize: optimize, max_pixels: max_pixels)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
|
|
121
|
+
backend.letter_avatar(
|
|
122
|
+
output: output,
|
|
123
|
+
size: size,
|
|
124
|
+
background_rgb: background_rgb,
|
|
125
|
+
letter: letter,
|
|
126
|
+
pointsize: pointsize,
|
|
127
|
+
font: font
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def backend
|
|
134
|
+
OperationBackends.for(config)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private_constant :TransformOperations
|
|
139
|
+
end
|
data/lib/safe_image/version.rb
CHANGED
|
@@ -6,18 +6,26 @@ module SafeImage
|
|
|
6
6
|
|
|
7
7
|
DIMENSIONS_RE = /\A(?:(?<percent>\d+(?:\.\d+)?)%|(?<w>\d*)x(?<h>\d*)(?<only_down>>)?|(?<pixels>\d+)@)\z/
|
|
8
8
|
|
|
9
|
-
def crop_north(input:, output:, width:, height:, format:, quality:
|
|
10
|
-
Native.crop_north(
|
|
9
|
+
def crop_north(input:, output:, width:, height:, format:, quality: QualityDefaults::JPEG, max_pixels: nil)
|
|
10
|
+
Native.crop_north(
|
|
11
|
+
input.to_s,
|
|
12
|
+
output.to_s,
|
|
13
|
+
Integer(width),
|
|
14
|
+
Integer(height),
|
|
15
|
+
format.to_s,
|
|
16
|
+
Integer(quality),
|
|
17
|
+
max_pixels
|
|
18
|
+
)
|
|
11
19
|
end
|
|
12
20
|
|
|
13
|
-
def downsize(input:, output:, dimensions:, format:, quality:
|
|
21
|
+
def downsize(input:, output:, dimensions:, format:, quality: QualityDefaults::JPEG, max_pixels: nil)
|
|
14
22
|
probe = SafeImage.probe(input, max_pixels: max_pixels)
|
|
15
23
|
scale = scale_for(probe.width, probe.height, dimensions)
|
|
16
24
|
# Never upscale, but always re-encode through the native saver — even on a
|
|
17
25
|
# no-op scale of 1.0 — so the output is metadata-stripped rather than a
|
|
18
26
|
# verbatim copy of the untrusted input bytes.
|
|
19
27
|
scale = [scale, 1.0].min
|
|
20
|
-
Native.resize(input.to_s, output.to_s, scale,
|
|
28
|
+
Native.resize(input.to_s, output.to_s, scale, Formats.normalize(format), Integer(quality), max_pixels)
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
def dominant_color(input, max_pixels: nil)
|
|
@@ -77,18 +85,11 @@ module SafeImage
|
|
|
77
85
|
}
|
|
78
86
|
end
|
|
79
87
|
|
|
80
|
-
def normalized_format(format)
|
|
81
|
-
format = format.to_s.downcase
|
|
82
|
-
format == "jpeg" ? "jpg" : format
|
|
83
|
-
end
|
|
84
|
-
|
|
85
88
|
def scale_for(width, height, dimensions)
|
|
86
89
|
dimensions = dimensions.to_s
|
|
87
90
|
match = DIMENSIONS_RE.match(dimensions) or raise ArgumentError, "unsupported dimensions: #{dimensions.inspect}"
|
|
88
91
|
|
|
89
|
-
if match[:percent]
|
|
90
|
-
return Float(match[:percent]) / 100.0
|
|
91
|
-
end
|
|
92
|
+
return Float(match[:percent]) / 100.0 if match[:percent]
|
|
92
93
|
|
|
93
94
|
if match[:pixels]
|
|
94
95
|
target_pixels = Float(match[:pixels])
|