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,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
- raise LimitError, "SVG dimensions exceed #{MAX_SVG_DIMENSION}px" if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION
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 = max_pixels || MAX_SVG_PIXELS
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 pull parser, enforcing the structural caps as
123
- # events arrive, so a hostile "millions of tiny elements" document is
124
- # rejected at the cap without ever retaining the multi-million-object DOM
125
- # that a parse-then-validate approach would build first. Returns the root
126
- # element's name and its attributes hash.
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
- parser = REXML::Parsers::PullParser.new(xml)
129
- depth = -1
130
- elements = 0
131
- attributes = 0
132
- root_name = nil
133
- root_attributes = nil
134
-
135
- while parser.has_next?
136
- event = parser.pull
137
- if event.start_element?
138
- depth += 1
139
- raise LimitError, "SVG nesting exceeds #{MAX_SVG_DEPTH}" if depth > MAX_SVG_DEPTH
140
-
141
- elements += 1
142
- raise LimitError, "SVG has too many elements" if elements > MAX_SVG_ELEMENTS
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 root required" unless root_name == "svg"
157
- [root_name, root_attributes]
158
- rescue REXML::ParseException => e
159
- raise InvalidImageError, "invalid SVG: #{e.message}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeImage
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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: 85, max_pixels: nil)
10
- Native.crop_north(input.to_s, output.to_s, Integer(width), Integer(height), format.to_s, Integer(quality), max_pixels)
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: 85, max_pixels: nil)
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, normalized_format(format), Integer(quality), max_pixels)
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])