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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/README.md +475 -281
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +267 -98
- data/lib/safe_image/fonts/DEJAVU-LICENSE +187 -0
- data/lib/safe_image/fonts/DejaVuSans.ttf +0 -0
- data/lib/safe_image/ico.rb +286 -0
- data/lib/safe_image/image_magick_backend.rb +39 -3
- data/lib/safe_image/imagemagick_policy/policy.xml +8 -1
- data/lib/safe_image/jpegli_backend.rb +3 -1
- data/lib/safe_image/native.rb +371 -1
- data/lib/safe_image/processor.rb +23 -69
- data/lib/safe_image/remote.rb +15 -3
- data/lib/safe_image/runner.rb +1 -1
- data/lib/safe_image/sandbox.rb +42 -16
- data/lib/safe_image/svg_metadata.rb +59 -29
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +57 -0
- data/lib/safe_image/vips_glue.rb +361 -0
- data/lib/safe_image.rb +143 -36
- metadata +30 -14
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
|
@@ -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
|
-
|
|
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
|
-
|
|
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",
|