safe_image 0.1.0 → 0.2.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.
@@ -0,0 +1,187 @@
1
+ Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
2
+ Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
3
+
4
+
5
+ Bitstream Vera Fonts Copyright
6
+ ------------------------------
7
+
8
+ Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
9
+ a trademark of Bitstream, Inc.
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of the fonts accompanying this license ("Fonts") and associated
13
+ documentation files (the "Font Software"), to reproduce and distribute the
14
+ Font Software, including without limitation the rights to use, copy, merge,
15
+ publish, distribute, and/or sell copies of the Font Software, and to permit
16
+ persons to whom the Font Software is furnished to do so, subject to the
17
+ following conditions:
18
+
19
+ The above copyright and trademark notices and this permission notice shall
20
+ be included in all copies of one or more of the Font Software typefaces.
21
+
22
+ The Font Software may be modified, altered, or added to, and in particular
23
+ the designs of glyphs or characters in the Fonts may be modified and
24
+ additional glyphs or characters may be added to the Fonts, only if the fonts
25
+ are renamed to names not containing either the words "Bitstream" or the word
26
+ "Vera".
27
+
28
+ This License becomes null and void to the extent applicable to Fonts or Font
29
+ Software that has been modified and is distributed under the "Bitstream
30
+ Vera" names.
31
+
32
+ The Font Software may be sold as part of a larger software package but no
33
+ copy of one or more of the Font Software typefaces may be sold by itself.
34
+
35
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
36
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
37
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
38
+ TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
39
+ FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
40
+ ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
41
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
42
+ THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
43
+ FONT SOFTWARE.
44
+
45
+ Except as contained in this notice, the names of Gnome, the Gnome
46
+ Foundation, and Bitstream Inc., shall not be used in advertising or
47
+ otherwise to promote the sale, use or other dealings in this Font Software
48
+ without prior written authorization from the Gnome Foundation or Bitstream
49
+ Inc., respectively. For further information, contact: fonts at gnome dot
50
+ org.
51
+
52
+ Arev Fonts Copyright
53
+ ------------------------------
54
+
55
+ Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
56
+
57
+ Permission is hereby granted, free of charge, to any person obtaining
58
+ a copy of the fonts accompanying this license ("Fonts") and
59
+ associated documentation files (the "Font Software"), to reproduce
60
+ and distribute the modifications to the Bitstream Vera Font Software,
61
+ including without limitation the rights to use, copy, merge, publish,
62
+ distribute, and/or sell copies of the Font Software, and to permit
63
+ persons to whom the Font Software is furnished to do so, subject to
64
+ the following conditions:
65
+
66
+ The above copyright and trademark notices and this permission notice
67
+ shall be included in all copies of one or more of the Font Software
68
+ typefaces.
69
+
70
+ The Font Software may be modified, altered, or added to, and in
71
+ particular the designs of glyphs or characters in the Fonts may be
72
+ modified and additional glyphs or characters may be added to the
73
+ Fonts, only if the fonts are renamed to names not containing either
74
+ the words "Tavmjong Bah" or the word "Arev".
75
+
76
+ This License becomes null and void to the extent applicable to Fonts
77
+ or Font Software that has been modified and is distributed under the
78
+ "Tavmjong Bah Arev" names.
79
+
80
+ The Font Software may be sold as part of a larger software package but
81
+ no copy of one or more of the Font Software typefaces may be sold by
82
+ itself.
83
+
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
88
+ TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
93
+
94
+ Except as contained in this notice, the name of Tavmjong Bah shall not
95
+ be used in advertising or otherwise to promote the sale, use or other
96
+ dealings in this Font Software without prior written authorization
97
+ from Tavmjong Bah. For further information, contact: tavmjong @ free
98
+ . fr.
99
+
100
+ TeX Gyre DJV Math
101
+ -----------------
102
+ Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
103
+
104
+ Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
105
+ (on behalf of TeX users groups) are in public domain.
106
+
107
+ Letters imported from Euler Fraktur from AMSfonts are (c) American
108
+ Mathematical Society (see below).
109
+ Bitstream Vera Fonts Copyright
110
+ Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
111
+ is a trademark of Bitstream, Inc.
112
+
113
+ Permission is hereby granted, free of charge, to any person obtaining a copy
114
+ of the fonts accompanying this license (“Fonts”) and associated
115
+ documentation
116
+ files (the “Font Software”), to reproduce and distribute the Font Software,
117
+ including without limitation the rights to use, copy, merge, publish,
118
+ distribute,
119
+ and/or sell copies of the Font Software, and to permit persons to whom
120
+ the Font Software is furnished to do so, subject to the following
121
+ conditions:
122
+
123
+ The above copyright and trademark notices and this permission notice
124
+ shall be
125
+ included in all copies of one or more of the Font Software typefaces.
126
+
127
+ The Font Software may be modified, altered, or added to, and in particular
128
+ the designs of glyphs or characters in the Fonts may be modified and
129
+ additional
130
+ glyphs or characters may be added to the Fonts, only if the fonts are
131
+ renamed
132
+ to names not containing either the words “Bitstream” or the word “Vera”.
133
+
134
+ This License becomes null and void to the extent applicable to Fonts or
135
+ Font Software
136
+ that has been modified and is distributed under the “Bitstream Vera”
137
+ names.
138
+
139
+ The Font Software may be sold as part of a larger software package but
140
+ no copy
141
+ of one or more of the Font Software typefaces may be sold by itself.
142
+
143
+ THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
144
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
145
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
146
+ TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
147
+ FOUNDATION
148
+ BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
149
+ SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
150
+ ACTION
151
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
152
+ INABILITY TO USE
153
+ THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
154
+ Except as contained in this notice, the names of GNOME, the GNOME
155
+ Foundation,
156
+ and Bitstream Inc., shall not be used in advertising or otherwise to promote
157
+ the sale, use or other dealings in this Font Software without prior written
158
+ authorization from the GNOME Foundation or Bitstream Inc., respectively.
159
+ For further information, contact: fonts at gnome dot org.
160
+
161
+ AMSFonts (v. 2.2) copyright
162
+
163
+ The PostScript Type 1 implementation of the AMSFonts produced by and
164
+ previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
165
+ available for general use. This has been accomplished through the
166
+ cooperation
167
+ of a consortium of scientific publishers with Blue Sky Research and Y&Y.
168
+ Members of this consortium include:
169
+
170
+ Elsevier Science IBM Corporation Society for Industrial and Applied
171
+ Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
172
+
173
+ In order to assure the authenticity of these fonts, copyright will be
174
+ held by
175
+ the American Mathematical Society. This is not meant to restrict in any way
176
+ the legitimate use of the fonts, such as (but not limited to) electronic
177
+ distribution of documents containing these fonts, inclusion of these fonts
178
+ into other public domain or commercial font collections or computer
179
+ applications, use of the outline data to create derivative fonts and/or
180
+ faces, etc. However, the AMS does require that the AMS copyright notice be
181
+ removed from any derivative versions of the fonts which have been altered in
182
+ any way. In addition, to ensure the fidelity of TeX documents using Computer
183
+ Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
184
+ has requested that any alterations which yield different font metrics be
185
+ given a different name.
186
+
187
+ $Id$
Binary file
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module SafeImage
6
+ # Pure-Ruby ICO container support, in the spirit of the REXML SVG path:
7
+ # the directory and legacy DIB payloads are parsed in memory-safe Ruby with
8
+ # explicit bounds checks, and pixel encoding is delegated to the hardened
9
+ # native libvips helpers. ImageMagick is never involved.
10
+ #
11
+ # PNG payloads (every modern favicon) are re-encoded through the native
12
+ # libvips PNG path rather than copied through verbatim, so output never
13
+ # contains attacker-controlled bytes. DIB payloads support the formats that
14
+ # exist in the wild: uncompressed BI_RGB at 1/4/8/24/32 bits per pixel plus
15
+ # the 1-bit AND transparency mask.
16
+ module Ico
17
+ module_function
18
+
19
+ MAX_BYTES = 5 * 1024 * 1024
20
+ MAX_ENTRIES = 256
21
+ # The directory caps entry dimensions at 256 (a stored 0 means 256); a DIB
22
+ # header claiming more is lying about the payload.
23
+ MAX_DIB_DIMENSION = 256
24
+ PNG_MAGIC = "\x89PNG\r\n\x1a\n".b.freeze
25
+ BI_RGB = 0
26
+
27
+ Entry = Data.define(:width, :height, :bpp, :offset, :size, :png)
28
+
29
+ def probe(path, max_pixels: nil)
30
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ data, entries = parse(path)
32
+ entry = largest(entries)
33
+ width, height = entry_dimensions(data, entry)
34
+ validate_pixels!(width, height, max_pixels)
35
+ {
36
+ width: width,
37
+ height: height,
38
+ frames: entries.length,
39
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
40
+ }
41
+ end
42
+
43
+ def frame_count(path, max_pixels: nil)
44
+ probe(path, max_pixels: max_pixels).fetch(:frames)
45
+ end
46
+
47
+ # Extracts the largest icon and writes it as PNG. Returns an info hash in
48
+ # the shape DiscourseCompat.result_from_info expects.
49
+ def convert_to_png(input, output, max_pixels: nil)
50
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ data, entries = parse(input)
52
+ entry = largest(entries)
53
+ output = PathSafety.ensure_safe_output_path!(output).to_s
54
+
55
+ width = height = nil
56
+ if entry.png
57
+ # Enforce the pixel cap from the IHDR dimensions before the payload
58
+ # reaches a decoder.
59
+ validate_pixels!(*entry_dimensions(data, entry), max_pixels)
60
+ payload = data.byteslice(entry.offset, entry.size)
61
+ Tempfile.create(["safe-image-ico", ".png"]) do |tmp|
62
+ tmp.binmode
63
+ tmp.write(payload)
64
+ tmp.close
65
+ # Sanitizing no-op resize: validates the PNG bytes, enforces the
66
+ # pixel cap and strips metadata on the way through libvips.
67
+ info = Native.resize(tmp.path, output, 1.0, "png", 100, max_pixels)
68
+ width = info.fetch(:width)
69
+ height = info.fetch(:height)
70
+ end
71
+ else
72
+ rgba, width, height = decode_rgba(data, entry)
73
+ validate_pixels!(width, height, max_pixels)
74
+ Native.png_from_rgba(rgba, width, height, output)
75
+ end
76
+
77
+ {
78
+ input_format: "ico",
79
+ output_format: "png",
80
+ width: width,
81
+ height: height,
82
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
83
+ }
84
+ end
85
+
86
+ def dominant_color(path, max_pixels: nil)
87
+ Tempfile.create(["safe-image-ico", ".png"]) do |tmp|
88
+ tmp.close
89
+ convert_to_png(path, tmp.path, max_pixels: max_pixels)
90
+ VipsBackend.dominant_color(tmp.path, max_pixels: max_pixels)
91
+ end
92
+ end
93
+
94
+ # -- container parsing ---------------------------------------------------
95
+
96
+ def parse(path)
97
+ path = PathSafety.ensure_regular_file!(path).to_s
98
+ size = File.size(path)
99
+ raise LimitError, "ico file has #{size} bytes, exceeds #{MAX_BYTES}" if size > MAX_BYTES
100
+ raise InvalidImageError, "ico file is truncated" if size < 6 + 16
101
+
102
+ data = File.binread(path)
103
+ reserved, type, count = data.unpack("vvv")
104
+ raise InvalidImageError, "not an ico file" unless reserved == 0 && type == 1
105
+ raise InvalidImageError, "ico directory is empty" if count.zero?
106
+ raise LimitError, "ico has #{count} entries, exceeds #{MAX_ENTRIES}" if count > MAX_ENTRIES
107
+
108
+ entries =
109
+ count.times.map do |index|
110
+ dir_offset = 6 + index * 16
111
+ raise InvalidImageError, "ico directory is truncated" if data.bytesize < dir_offset + 16
112
+
113
+ w8, h8, _colors, _reserved, _planes, bpp, bytes, img_offset =
114
+ data.byteslice(dir_offset, 16).unpack("CCCCvvVV")
115
+ if bytes < 16 || img_offset < 6 + count * 16 || img_offset + bytes > data.bytesize
116
+ raise InvalidImageError, "ico entry #{index} is out of bounds"
117
+ end
118
+
119
+ Entry.new(
120
+ width: w8.zero? ? 256 : w8,
121
+ height: h8.zero? ? 256 : h8,
122
+ bpp: bpp,
123
+ offset: img_offset,
124
+ size: bytes,
125
+ png: data.byteslice(img_offset, 8) == PNG_MAGIC
126
+ )
127
+ end
128
+
129
+ [data, entries]
130
+ end
131
+
132
+ def largest(entries)
133
+ entries.max_by { |entry| [entry.width * entry.height, entry.bpp] }
134
+ end
135
+
136
+ # PNG payloads carry their real dimensions in the IHDR chunk; the
137
+ # one-byte directory fields saturate at 256.
138
+ def entry_dimensions(data, entry)
139
+ return [entry.width, entry.height] unless entry.png
140
+ raise InvalidImageError, "png payload is truncated" if entry.size < 24
141
+ data.byteslice(entry.offset + 16, 8).unpack("NN")
142
+ end
143
+
144
+ def validate_pixels!(width, height, max_pixels)
145
+ raise InvalidImageError, "ico has invalid dimensions" if width.nil? || height.nil? || width < 1 || height < 1
146
+ limit = max_pixels ? Integer(max_pixels) : DEFAULT_MAX_PIXELS
147
+ pixels = width * height
148
+ raise LimitError, "image has #{pixels} pixels, exceeds #{limit}" if pixels > limit
149
+ end
150
+
151
+ # -- DIB payload decoding ------------------------------------------------
152
+
153
+ # Decodes a BITMAPINFOHEADER payload (XOR bitmap + 1-bit AND mask) into a
154
+ # top-down RGBA buffer. Returns [rgba_bytes, width, height].
155
+ def decode_rgba(data, entry)
156
+ payload = data.byteslice(entry.offset, entry.size)
157
+ raise InvalidImageError, "dib payload is truncated" if payload.bytesize < 40
158
+
159
+ header_size, width, height2, _planes, bpp, compression, _img_size, _xppm, _yppm, colors_used =
160
+ payload.unpack("Vl<l<vvVVl<l<V")
161
+ raise InvalidImageError, "unsupported dib header (size #{header_size})" if header_size != 40
162
+ raise InvalidImageError, "unsupported dib compression #{compression}" unless compression == BI_RGB
163
+ raise InvalidImageError, "unsupported dib bit depth #{bpp}" unless [1, 4, 8, 24, 32].include?(bpp)
164
+
165
+ top_down = height2.negative?
166
+ height = height2.abs / 2
167
+ if width < 1 || height < 1 || width > MAX_DIB_DIMENSION || height > MAX_DIB_DIMENSION || height2.abs.odd?
168
+ raise InvalidImageError, "dib dimensions are invalid (#{width}x#{height2})"
169
+ end
170
+
171
+ palette = []
172
+ palette_offset = header_size
173
+ if bpp <= 8
174
+ palette_count = colors_used.zero? ? (1 << bpp) : colors_used
175
+ raise InvalidImageError, "dib palette is invalid" if palette_count > 1 << bpp
176
+ raise InvalidImageError, "dib palette is truncated" if payload.bytesize < palette_offset + palette_count * 4
177
+ palette = payload.byteslice(palette_offset, palette_count * 4).unpack("C*").each_slice(4).map do |b, g, r, _x|
178
+ [r, g, b]
179
+ end
180
+ palette_offset += palette_count * 4
181
+ end
182
+
183
+ xor_stride = ((width * bpp + 31) / 32) * 4
184
+ and_stride = ((width + 31) / 32) * 4
185
+ xor_bytes = xor_stride * height
186
+ and_bytes = and_stride * height
187
+ raise InvalidImageError, "dib pixel data is truncated" if payload.bytesize < palette_offset + xor_bytes + and_bytes
188
+
189
+ xor_data = payload.byteslice(palette_offset, xor_bytes)
190
+ and_data = payload.byteslice(palette_offset + xor_bytes, and_bytes)
191
+
192
+ rgba =
193
+ if bpp == 32
194
+ decode_32bpp(xor_data, and_data, width, height, and_stride, top_down)
195
+ else
196
+ decode_low_bpp(xor_data, and_data, palette, width, height, bpp, xor_stride, and_stride, top_down)
197
+ end
198
+
199
+ [rgba, width, height]
200
+ end
201
+
202
+ # 32bpp is the format every real-world DIB favicon uses, so it gets a
203
+ # bulk path: reorder rows with byteslice, then swap BGRA to RGBA in one
204
+ # unpack/map!/pack pass. Reading big-endian makes the swap a single
205
+ # rotate-right-by-8.
206
+ def decode_32bpp(xor_data, and_data, width, height, and_stride, top_down)
207
+ stride = width * 4
208
+ unless top_down
209
+ xor_data = (0...height).map { |y| xor_data.byteslice((height - 1 - y) * stride, stride) }.join
210
+ end
211
+ pixels = xor_data.unpack("N*")
212
+
213
+ if alpha_all_zero?(xor_data)
214
+ # Pre-alpha icons leave every alpha byte zero and rely on the 1-bit
215
+ # AND mask (the Windows convention). A mask with no set bits means
216
+ # fully opaque, which bulk-converts; otherwise the rotated pixel
217
+ # keeps alpha 0 and only unmasked pixels need the opaque byte set.
218
+ if and_data.count("\x00") == and_data.bytesize
219
+ pixels.map! { |x| (x >> 8) | 0xFF000000 }
220
+ else
221
+ i = 0
222
+ height.times do |out_y|
223
+ mask_offset = (top_down ? out_y : height - 1 - out_y) * and_stride
224
+ width.times do |x|
225
+ value = pixels[i] >> 8
226
+ value |= 0xFF000000 if (and_data.getbyte(mask_offset + (x >> 3)) & (0x80 >> (x & 7))).zero?
227
+ pixels[i] = value
228
+ i += 1
229
+ end
230
+ end
231
+ end
232
+ else
233
+ pixels.map! { |x| (x >> 8) | ((x & 0xFF) << 24) }
234
+ end
235
+
236
+ pixels.pack("V*")
237
+ end
238
+
239
+ def decode_low_bpp(xor_data, and_data, palette, width, height, bpp, xor_stride, and_stride, top_down)
240
+ # Precomputed 4-byte RGBA chunks per palette entry (opaque and masked
241
+ # variants) turn the pixel body into a single string append.
242
+ if bpp <= 8
243
+ opaque = palette.map { |r, g, b| [r, g, b, 255].pack("C4") }
244
+ transparent = palette.map { |r, g, b| [r, g, b, 0].pack("C4") }
245
+ end
246
+
247
+ rgba = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
248
+ height.times do |out_y|
249
+ src_y = top_down ? out_y : height - 1 - out_y
250
+ xor_row = xor_data.byteslice(src_y * xor_stride, xor_stride)
251
+ and_row = and_data.byteslice(src_y * and_stride, and_stride)
252
+ width.times do |x|
253
+ masked = (and_row.getbyte(x >> 3) & (0x80 >> (x & 7))).positive?
254
+ if bpp == 24
255
+ b = xor_row.getbyte(x * 3)
256
+ g = xor_row.getbyte(x * 3 + 1)
257
+ r = xor_row.getbyte(x * 3 + 2)
258
+ rgba << r << g << b << (masked ? 0 : 255)
259
+ next
260
+ end
261
+
262
+ index =
263
+ case bpp
264
+ when 8 then xor_row.getbyte(x)
265
+ when 4 then (byte = xor_row.getbyte(x >> 1)
266
+ x.even? ? byte >> 4 : byte & 0x0F)
267
+ else (xor_row.getbyte(x >> 3) >> (7 - (x & 7))) & 1
268
+ end
269
+ rgba << (masked ? transparent : opaque).fetch(index) do
270
+ raise InvalidImageError, "dib palette index #{index} is out of range"
271
+ end
272
+ end
273
+ end
274
+ rgba
275
+ end
276
+
277
+ # Possessive quantifier: the regex engine scans 4-byte groups in C with
278
+ # no backtracking, so the worst case (an all-zero legacy icon) stays
279
+ # sub-millisecond where a getbyte loop takes milliseconds.
280
+ ALPHA_ALL_ZERO = /\A(?:.{3}\x00)*+\z/mn
281
+
282
+ def alpha_all_zero?(xor_data)
283
+ xor_data.match?(ALPHA_ALL_ZERO)
284
+ end
285
+ end
286
+ end
@@ -14,7 +14,8 @@ module SafeImage
14
14
  "heic" => "heic",
15
15
  "heif" => "heic",
16
16
  "avif" => "heic",
17
- "ico" => "ico"
17
+ "ico" => "ico",
18
+ "jxl" => "jxl"
18
19
  }.freeze
19
20
 
20
21
  IMAGEMAGICK_LIMIT_ARGS = [
@@ -167,6 +168,38 @@ module SafeImage
167
168
  1
168
169
  end
169
170
 
171
+ # Averages the whole image down to one pixel and reports it as an RRGGBB
172
+ # hex string, mirroring Discourse's Upload#calculate_dominant_color!.
173
+ def dominant_color(path, timeout: Runner::DEFAULT_TIMEOUT)
174
+ command = convert_command
175
+ path = PathSafety.ensure_imagemagick_input_file!(path)
176
+ ext = File.extname(path).delete_prefix(".").downcase
177
+ decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
178
+ stdout, = Runner.run!(
179
+ [
180
+ command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{path}[0]",
181
+ "-depth", "8",
182
+ "-resize", "1x1",
183
+ "-define", "histogram:unique-colors=true",
184
+ "-format", "%c",
185
+ "histogram:info:"
186
+ ],
187
+ timeout: timeout
188
+ )
189
+
190
+ # Typical output: `1: (110,116,93) #6F745E srgb(110,116,93)`. Alpha adds
191
+ # two more hex digits; grayscale images report one channel (two digits,
192
+ # four with alpha) instead of three.
193
+ digits = stdout[/#(\h+)/, 1]
194
+ hex =
195
+ case digits&.length
196
+ when 6, 8 then digits[0, 6]
197
+ when 2, 4 then digits[0, 2] * 3
198
+ end
199
+ raise InvalidImageError, "could not parse dominant color from ImageMagick output: #{stdout.strip.inspect}" if hex.nil?
200
+ hex.upcase
201
+ end
202
+
170
203
  def letter_avatar(output:, size:, background_rgb:, letter:, pointsize:, font: "NimbusSans-Regular", timeout: Runner::DEFAULT_TIMEOUT)
171
204
  command = convert_command
172
205
  output = PathSafety.ensure_safe_output_path!(output).to_s
@@ -216,7 +249,8 @@ module SafeImage
216
249
  "gif" => "gif",
217
250
  "webp" => "webp",
218
251
  "avif" => "avif",
219
- "ico" => "ico"
252
+ "ico" => "ico",
253
+ "jxl" => "jxl"
220
254
  }.fetch(normalized) { raise UnsupportedFormatError, "unsupported ImageMagick output format: #{normalized.inspect}" }
221
255
  "#{coder}:#{output}"
222
256
  end
@@ -250,7 +284,9 @@ module SafeImage
250
284
  Runner.run!(argv, timeout: timeout)
251
285
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
252
286
 
253
- info = Native.probe(output)
287
+ # Output dimensions via the fast native header read, or identify when
288
+ # libvips is not installed (this backend must work without it).
289
+ info = VipsGlue.available? ? Native.probe(output) : probe(output)
254
290
  {
255
291
  input_format: input_format == "jpeg" ? "jpg" : input_format,
256
292
  output_format: output_format == "jpeg" ? "jpg" : output_format,
@@ -6,7 +6,14 @@
6
6
  <policy domain="path" rights="none" pattern="@*" />
7
7
 
8
8
  <policy domain="coder" rights="none" pattern="*" />
9
- <policy domain="coder" rights="read|write" pattern="{JPEG,JPG,PNG,GIF,WEBP,HEIC,HEIF,AVIF,ICO,ICC,ICM,XC}" />
9
+ <policy domain="coder" rights="read|write" pattern="{JPEG,JPG,PNG,GIF,WEBP,HEIC,HEIF,AVIF,ICO,ICC,ICM,XC,JXL}" />
10
+
11
+ <!-- Output-only coders for dominant_color, which writes the histogram
12
+ colour summary to stdout via histogram-to-info output. Write rights
13
+ only; neither is a decodable input format. NOTE: ImageMagick parses
14
+ this file with a hand-rolled tokenizer, not an XML parser; a backtick
15
+ or apostrophe in a comment silently truncates the policy here. -->
16
+ <policy domain="coder" rights="write" pattern="{HISTOGRAM,INFO}" />
10
17
 
11
18
  <!-- Ghostscript-backed / document / vector-ish formats: deny explicitly even
12
19
  if a broader system policy is present. -->
@@ -65,7 +65,9 @@ module SafeImage
65
65
  raise Error, "cjpegli did not create output" unless tmp_path.file? && File.size(tmp_path).positive?
66
66
  FileUtils.mv(tmp_path, output_path)
67
67
 
68
- info = Native.probe(output_path.to_s)
68
+ # cjpegli works without libvips; fall back to identify for the
69
+ # output dimensions when the native header read is unavailable.
70
+ info = VipsGlue.available? ? Native.probe(output_path.to_s) : ImageMagickBackend.probe(output_path.to_s)
69
71
  {
70
72
  input_format: input_format,
71
73
  output_format: "jpg",