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,23 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "pathname"
|
|
5
|
-
require "tempfile"
|
|
6
|
-
|
|
7
3
|
module SafeImage
|
|
8
4
|
module JpegliBackend
|
|
9
5
|
module_function
|
|
10
6
|
|
|
11
|
-
DIRECT_INPUTS =
|
|
7
|
+
DIRECT_INPUTS = Formats::CJPEGLI_DIRECT_INPUTS
|
|
12
8
|
CHROMA_SUBSAMPLING = %w[420 422 444].freeze
|
|
13
|
-
DEFAULT_QUALITY =
|
|
9
|
+
DEFAULT_QUALITY = QualityDefaults::JPEG
|
|
14
10
|
|
|
15
11
|
def available?
|
|
16
12
|
Runner.available?("cjpegli")
|
|
17
13
|
end
|
|
18
14
|
|
|
19
15
|
def suitable_direct_input?(input)
|
|
20
|
-
|
|
16
|
+
Formats.cjpegli_direct_input?(normalized_ext(input))
|
|
21
17
|
end
|
|
22
18
|
|
|
23
19
|
def convert(input:, output:, quality: DEFAULT_QUALITY, chroma_subsampling: :auto, timeout: Runner::DEFAULT_TIMEOUT)
|
|
@@ -28,16 +24,44 @@ module SafeImage
|
|
|
28
24
|
ensure_jpeg_output!(output)
|
|
29
25
|
|
|
30
26
|
input_format = normalized_ext(input)
|
|
31
|
-
|
|
27
|
+
if DIRECT_INPUTS.none? { |candidate| candidate == input_format }
|
|
32
28
|
raise UnsupportedFormatError, "cjpegli direct input format is unsupported: #{input_format.inspect}"
|
|
33
29
|
end
|
|
34
30
|
|
|
35
31
|
quality = validate_quality!(quality)
|
|
36
32
|
chroma_subsampling = validate_chroma_subsampling!(chroma_subsampling, input_format: input_format)
|
|
37
|
-
encode(
|
|
33
|
+
encode(
|
|
34
|
+
input: input,
|
|
35
|
+
output: output,
|
|
36
|
+
quality: quality,
|
|
37
|
+
chroma_subsampling: chroma_subsampling,
|
|
38
|
+
timeout: timeout,
|
|
39
|
+
input_format: input_format
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def encode_generated_jpeg(output:, quality: DEFAULT_QUALITY, chroma_subsampling: :auto, input_format: nil)
|
|
44
|
+
StagedOutput.with_temp_path_near(output, suffix: ".safe-image.png") do |tmp_path|
|
|
45
|
+
decoded = yield tmp_path
|
|
46
|
+
source_format = input_format || decoded.fetch(:input_format)
|
|
47
|
+
encode(
|
|
48
|
+
input: tmp_path,
|
|
49
|
+
output: output,
|
|
50
|
+
quality: quality,
|
|
51
|
+
chroma_subsampling: validate_chroma_subsampling!(chroma_subsampling, input_format: source_format),
|
|
52
|
+
input_format: source_format
|
|
53
|
+
)
|
|
54
|
+
end
|
|
38
55
|
end
|
|
39
56
|
|
|
40
|
-
def encode(
|
|
57
|
+
def encode(
|
|
58
|
+
input:,
|
|
59
|
+
output:,
|
|
60
|
+
quality: DEFAULT_QUALITY,
|
|
61
|
+
chroma_subsampling: "420",
|
|
62
|
+
timeout: Runner::DEFAULT_TIMEOUT,
|
|
63
|
+
input_format: nil
|
|
64
|
+
)
|
|
41
65
|
raise UnsupportedFormatError, "cjpegli is not installed" unless available?
|
|
42
66
|
|
|
43
67
|
input = PathSafety.ensure_regular_file!(input)
|
|
@@ -49,37 +73,32 @@ module SafeImage
|
|
|
49
73
|
quality = validate_quality!(quality)
|
|
50
74
|
chroma_subsampling = validate_chroma_subsampling!(chroma_subsampling, input_format: input_format)
|
|
51
75
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
chroma_subsampling: chroma_subsampling
|
|
79
|
-
}
|
|
80
|
-
ensure
|
|
81
|
-
FileUtils.rm_f(tmp_path)
|
|
82
|
-
end
|
|
76
|
+
info =
|
|
77
|
+
StagedOutput.replace(output_path, suffix: ".cjpegli.jpg") do |tmp_path|
|
|
78
|
+
argv = [
|
|
79
|
+
"cjpegli",
|
|
80
|
+
input.to_s,
|
|
81
|
+
tmp_path.to_s,
|
|
82
|
+
"--quality=#{quality}",
|
|
83
|
+
"--chroma_subsampling=#{chroma_subsampling}"
|
|
84
|
+
]
|
|
85
|
+
Runner.run!(argv, timeout: timeout, read: [input.to_s], write: [output_path.dirname.to_s])
|
|
86
|
+
raise Error, "cjpegli did not create output" unless tmp_path.file? && File.size(tmp_path).positive?
|
|
87
|
+
|
|
88
|
+
# cjpegli works without libvips in the Ruby process; fall back to
|
|
89
|
+
# identify when the helper/native header read is unavailable.
|
|
90
|
+
info = Native.available? ? Native.probe(tmp_path.to_s) : ImageMagickBackend.probe(tmp_path.to_s)
|
|
91
|
+
{
|
|
92
|
+
input_format: input_format,
|
|
93
|
+
output_format: "jpg",
|
|
94
|
+
width: info.fetch(:width),
|
|
95
|
+
height: info.fetch(:height),
|
|
96
|
+
duration_ms: info.fetch(:duration_ms),
|
|
97
|
+
encoder: "cjpegli",
|
|
98
|
+
chroma_subsampling: chroma_subsampling
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
info
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
def validate_quality!(quality)
|
|
@@ -94,7 +113,9 @@ module SafeImage
|
|
|
94
113
|
value = "444" if value.to_sym == :auto && input_format.to_s == "png"
|
|
95
114
|
value = "420" if value.to_sym == :auto
|
|
96
115
|
value = value.to_s
|
|
97
|
-
|
|
116
|
+
if CHROMA_SUBSAMPLING.none? { |candidate| candidate == value }
|
|
117
|
+
raise ArgumentError, "chroma_subsampling must be one of #{CHROMA_SUBSAMPLING.join(", ")}"
|
|
118
|
+
end
|
|
98
119
|
value
|
|
99
120
|
end
|
|
100
121
|
|
|
@@ -104,8 +125,7 @@ module SafeImage
|
|
|
104
125
|
end
|
|
105
126
|
|
|
106
127
|
def normalized_ext(path)
|
|
107
|
-
|
|
108
|
-
ext == "jpeg" ? "jpg" : ext
|
|
128
|
+
Formats.extension(path)
|
|
109
129
|
end
|
|
110
130
|
end
|
|
111
131
|
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module SafeImage
|
|
6
|
+
# Inline implementations for local, read-only image metadata operations.
|
|
7
|
+
class MetadataOperations < OperationSet
|
|
8
|
+
def probe(path, max_pixels: nil)
|
|
9
|
+
path = PathSafety.local_path(path)
|
|
10
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
11
|
+
|
|
12
|
+
case File.extname(path).downcase
|
|
13
|
+
when ".svg"
|
|
14
|
+
info = SvgMetadata.probe(path, max_pixels: max_pixels)
|
|
15
|
+
Result.metadata(
|
|
16
|
+
input: File.expand_path(path),
|
|
17
|
+
input_format: "svg",
|
|
18
|
+
width: info.fetch(:width),
|
|
19
|
+
height: info.fetch(:height),
|
|
20
|
+
backend: :svg_metadata,
|
|
21
|
+
duration_ms: info.fetch(:duration_ms)
|
|
22
|
+
)
|
|
23
|
+
when ".ico"
|
|
24
|
+
# Pure-Ruby directory parse; reports the largest entry's dimensions.
|
|
25
|
+
info = Ico.probe(path, max_pixels: max_pixels)
|
|
26
|
+
Result.metadata(
|
|
27
|
+
input: File.expand_path(path),
|
|
28
|
+
input_format: "ico",
|
|
29
|
+
width: info.fetch(:width),
|
|
30
|
+
height: info.fetch(:height),
|
|
31
|
+
backend: :ico_metadata,
|
|
32
|
+
duration_ms: info.fetch(:duration_ms)
|
|
33
|
+
)
|
|
34
|
+
else
|
|
35
|
+
case config.backend
|
|
36
|
+
when :vips
|
|
37
|
+
Processor.new(max_pixels: max_pixels, config: config).probe(path)
|
|
38
|
+
when :imagemagick
|
|
39
|
+
info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
|
|
40
|
+
Result.metadata(
|
|
41
|
+
input: File.expand_path(path),
|
|
42
|
+
input_format: info.fetch(:input_format),
|
|
43
|
+
width: info.fetch(:width),
|
|
44
|
+
height: info.fetch(:height),
|
|
45
|
+
backend: :imagemagick,
|
|
46
|
+
duration_ms: info.fetch(:duration_ms)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def type(path, max_pixels: nil)
|
|
53
|
+
fastimage_type(probe(path, max_pixels: max_pixels).input_format)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def size(path, max_pixels: nil)
|
|
57
|
+
result = probe(path, max_pixels: max_pixels)
|
|
58
|
+
[result.width, result.height]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def dimensions(path, max_pixels: nil)
|
|
62
|
+
size(path, max_pixels: max_pixels)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def info(path, max_pixels: nil, animated: false, orientation: false)
|
|
66
|
+
result = probe(path, max_pixels: max_pixels)
|
|
67
|
+
type = fastimage_type(result.input_format)
|
|
68
|
+
Info.new(
|
|
69
|
+
path: result.input,
|
|
70
|
+
type: type,
|
|
71
|
+
width: result.width,
|
|
72
|
+
height: result.height,
|
|
73
|
+
size: [result.width, result.height],
|
|
74
|
+
animated: animated ? animated?(path, max_pixels: max_pixels) : nil,
|
|
75
|
+
orientation: orientation ? orientation(path, max_pixels: max_pixels) : nil
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def orientation(path, max_pixels: nil)
|
|
80
|
+
case File.extname(PathSafety.local_path(path)).downcase
|
|
81
|
+
when ".svg", ".ico"
|
|
82
|
+
# No EXIF orientation in either format; upright by definition.
|
|
83
|
+
1
|
|
84
|
+
else
|
|
85
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
86
|
+
case config.backend
|
|
87
|
+
when :vips
|
|
88
|
+
# Header-only native read.
|
|
89
|
+
VipsBackend.orientation(path, max_pixels: max_pixels)
|
|
90
|
+
when :imagemagick
|
|
91
|
+
# Probe first: rejects undecodable files and enforces the pixel cap.
|
|
92
|
+
ImageMagickBackend.probe(path, max_pixels: max_pixels)
|
|
93
|
+
ImageMagickBackend.orientation(path)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def dominant_color(path, max_pixels: nil)
|
|
99
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
100
|
+
case config.backend
|
|
101
|
+
when :vips
|
|
102
|
+
if File.extname(PathSafety.local_path(path)).downcase == ".ico"
|
|
103
|
+
# The configured backend is vips; ICO bytes are decoded by the
|
|
104
|
+
# pure-Ruby parser and the extracted PNG is averaged by the vips helper.
|
|
105
|
+
vips_ico_dominant_color(path, max_pixels: max_pixels)
|
|
106
|
+
else
|
|
107
|
+
VipsBackend.dominant_color(path, max_pixels: max_pixels)
|
|
108
|
+
end
|
|
109
|
+
when :imagemagick
|
|
110
|
+
imagemagick_dominant_color(path, max_pixels: max_pixels)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def frame_count(path, max_pixels: nil)
|
|
115
|
+
backend.frame_count(path, max_pixels: max_pixels)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def animated?(path, max_pixels: nil)
|
|
119
|
+
return false if File.extname(PathSafety.local_path(path)).downcase == ".svg"
|
|
120
|
+
|
|
121
|
+
backend.frame_count(path, max_pixels: max_pixels).to_i > 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.fastimage_type(format)
|
|
125
|
+
format.to_s == "jpg" ? :jpeg : format.to_s.to_sym
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def vips_ico_dominant_color(path, max_pixels:)
|
|
131
|
+
Tempfile.create(%w[safe-image-ico .png]) do |tmp|
|
|
132
|
+
tmp.close
|
|
133
|
+
Ico.convert_to_png(path, tmp.path, max_pixels: max_pixels)
|
|
134
|
+
VipsBackend.dominant_color(tmp.path, max_pixels: max_pixels)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def imagemagick_dominant_color(path, max_pixels:)
|
|
139
|
+
# Probe first: rejects undecodable files and enforces the pixel cap before
|
|
140
|
+
# ImageMagick fully decodes the image to average it.
|
|
141
|
+
probe(path, max_pixels: max_pixels)
|
|
142
|
+
ImageMagickBackend.dominant_color(path)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def fastimage_type(format)
|
|
146
|
+
self.class.fastimage_type(format)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def backend
|
|
150
|
+
OperationBackends.for(config)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private_constant :MetadataOperations
|
|
155
|
+
end
|