safe_image 0.3.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -12
  3. data/README.md +140 -287
  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 +42 -40
  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 -290
  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 +225 -98
  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 +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  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 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -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 = %w[png].freeze
7
+ DIRECT_INPUTS = Formats::CJPEGLI_DIRECT_INPUTS
12
8
  CHROMA_SUBSAMPLING = %w[420 422 444].freeze
13
- DEFAULT_QUALITY = 85
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
- DIRECT_INPUTS.include?(normalized_ext(input))
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
- unless DIRECT_INPUTS.include?(input_format)
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(input: input, output: output, quality: quality, chroma_subsampling: chroma_subsampling, timeout: timeout, input_format: input_format)
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(input:, output:, quality: DEFAULT_QUALITY, chroma_subsampling: "420", timeout: Runner::DEFAULT_TIMEOUT, input_format: nil)
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
- tmp = Tempfile.new([output_path.basename(".*").to_s, ".cjpegli.jpg"], output_path.dirname.to_s)
53
- tmp_path = Pathname.new(tmp.path)
54
- tmp.close
55
-
56
- begin
57
- argv = [
58
- "cjpegli",
59
- input.to_s,
60
- tmp_path.to_s,
61
- "--quality=#{quality}",
62
- "--chroma_subsampling=#{chroma_subsampling}"
63
- ]
64
- Runner.run!(argv, timeout: timeout, read: [input.to_s], write: [output_path.dirname.to_s])
65
- raise Error, "cjpegli did not create output" unless tmp_path.file? && File.size(tmp_path).positive?
66
- FileUtils.mv(tmp_path, output_path)
67
-
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)
71
- {
72
- input_format: input_format,
73
- output_format: "jpg",
74
- width: info.fetch(:width),
75
- height: info.fetch(:height),
76
- duration_ms: info.fetch(:duration_ms),
77
- encoder: "cjpegli",
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
- raise ArgumentError, "chroma_subsampling must be one of #{CHROMA_SUBSAMPLING.join(", ")}" unless CHROMA_SUBSAMPLING.include?(value)
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
- ext = File.extname(path.to_s).delete_prefix(".").downcase
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