oximg 0.17.0-aarch64-linux-gnu → 0.18.1-aarch64-linux-gnu

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a1f78e614dca7f97462527ab382bded6c4ad8e21469bd91787cb9be98852cf1
4
- data.tar.gz: e819391140b3a0054fe09133b9ecff161c251424c07b93faceb694cb9befcb9d
3
+ metadata.gz: d4d3cb7265d61a0d89d9851f6e0938951ca773603a02dcdc19ad5b8abf3d68de
4
+ data.tar.gz: 7cfa66df5ffac827bc245ddd387cc321054dabd6f97c97a13bf3e6b3e8ad50e5
5
5
  SHA512:
6
- metadata.gz: 7e012517b83bfcd0973f84c1e1ddea18b366ab2059529b30ab49a9732b9bbcf74615e5287fc233ec6614633faa45160cecdb685e5782e37370c20c6fd90a4c4f
7
- data.tar.gz: 0a7e5b34c899973b7d0def9d0f1571ba6023b08b4f0dde093b029cdc67818c25e38c20c66df73b8cae4e4b1a125067350c2406531ae892ba6a3fff5c3ccda976
6
+ metadata.gz: 1be6c2171b1eb682a4d495d3130eca32b7e856db393a5319aba109ada1f5d5cdd1e636ebf8072243894b58d6801849aa0cee30cb34d91adee4f0ff4593bfeb53
7
+ data.tar.gz: 5c2cf3b537d675e748cd62e3787a1008099ab2e16103ecdfee11d80a8e4b1535f81a5ebc3e3a7b5124410948042b230269f7ffae0dec5a75ea5f91770bfa6dbc
data/README.md CHANGED
@@ -18,6 +18,7 @@ gem "oximg"
18
18
  ```ruby
19
19
  Oximg.resize("in.jpg", "out.jpg", width: 750)
20
20
  Oximg.resize("in.jpg", "out.webp", width: 750, height: 500, quality: 80)
21
+ Oximg.strip("upload.jpg", "clean.jpg")
21
22
  Oximg.probe("in.jpg")
22
23
  #=> {content_type: "image/jpeg", format: :jpeg, width: 4000, height: 3000}
23
24
  ```
@@ -30,7 +31,8 @@ the name does not say, and `:gif` is not a `format:` you can ask for.
30
31
 
31
32
  ```ruby
32
33
  Oximg.resize(source, destination, width: 0, height: 0,
33
- quality: nil, format: nil, preset: nil)
34
+ quality: nil, format: nil, preset: nil,
35
+ icc: nil, auto_rotate: nil)
34
36
  ```
35
37
 
36
38
  The source is fitted within `width` x `height` and **never enlarged**. A
@@ -49,16 +51,104 @@ Oximg.resize("photo.jpg", "smaller.jpg", quality: 70)
49
51
  | `quality` | 1–100, default 80. JPEG quality |
50
52
  | `format` | `:jpg`, `:jpeg`, `:png`, `:webp`, `:avif`. Defaults to the destination's extension, else the source's own format |
51
53
  | `preset` | `:jpegli` (default — maximum quality per byte), `:fast`, `:small` |
54
+ | `icc` | `:keep` or `:strip` the source's colour profile. Default: `OXIMG_ICC`, else keep |
55
+ | `auto_rotate` | `false` keeps the stored orientation, `true` applies the EXIF one. Default: `OXIMG_AUTO_ROTATE`, else true |
56
+
57
+ `icc` and `auto_rotate` apply to that call only, and win over the
58
+ environment, so a threaded job runner can treat one image differently
59
+ without touching `ENV`.
52
60
 
53
61
  `resize` returns the destination path and raises `Oximg::ProcessingError`
54
62
  on failure, carrying the binary's own message and exit status. Paths are
55
63
  expanded before they reach the CLI, so an uploaded file called `-q.jpg`
56
64
  cannot be read as a flag.
57
65
 
66
+ ## Stripping metadata
67
+
68
+ ```ruby
69
+ Oximg.strip(source, destination, icc: nil, env: nil)
70
+ ```
71
+
72
+ Removes EXIF (GPS, camera, timestamps), XMP, IPTC and comments from a
73
+ JPEG, PNG or WebP **without re-encoding**. Decoded pixels are identical,
74
+ and it costs a fraction of a `0 x 0` resize in time and memory. This
75
+ replaces `saver(strip: true)` (libvips) or `-strip` (ImageMagick). A
76
+ rotated photo keeps an EXIF block holding only its orientation, so it
77
+ still displays upright. `icc: :strip` also drops the colour profile.
78
+ The default is `:keep`, or `OXIMG_ICC`. Dropping a CMYK JPEG's profile
79
+ raises `Oximg::ProcessingError`, as do GIF and AVIF sources.
80
+
81
+ ## Knobs, per process or per call
82
+
58
83
  Everything the server tunes through `OXIMG_*` environment variables
59
84
  applies here too, and is validated the same fail-closed way: a typo'd
60
85
  knob is a startup error, never a silent default.
61
86
 
87
+ To set one for a single call, pass `env:` to `resize` or `strip`:
88
+
89
+ ```ruby
90
+ Oximg.resize("in.jpg", "out.jpg", width: 750, env: {"OXIMG_JPEG_PROGRESSIVE" => "0"})
91
+ ```
92
+
93
+ Each call spawns its own process, and `env:` reaches that process
94
+ only: `ENV` is never touched, so concurrent calls in a threaded job
95
+ runner each get their own settings. Names must be in `Oximg::KNOBS`
96
+ (the pipeline knobs; server settings such as `OXIMG_KEY` are refused),
97
+ values are Strings or Integers, and `nil` unsets a name for that call.
98
+ An unknown name raises `ArgumentError` before anything runs; a bad
99
+ value is refused by the binary (`ProcessingError`, status 2).
100
+
101
+ ## Probing
102
+
103
+ ```ruby
104
+ Oximg.probe("in.jpg") # a path
105
+ Oximg.probe(Pathname("in.jpg")) # also a path
106
+ Oximg.probe(upload.tempfile) # anything with #path
107
+ Oximg.probe(File.open("in.jpg", "rb")) # an open File
108
+ Oximg.probe(StringIO.new(bytes)) # an IO: bytes in memory
109
+ #=> {content_type: "image/jpeg", format: :jpeg, width: 4000, height: 3000}
110
+ ```
111
+
112
+ `probe` reads the header only and decodes no pixels. The dimensions are
113
+ the stored ones, before any EXIF rotation. A String is always a path,
114
+ so bytes already in memory go in as a `StringIO`. An open `File` is
115
+ read from its start, its position untouched, and it is the file you
116
+ hold that is read, even if its name has since been replaced, renamed,
117
+ or was relative to a directory the process has left. A closed or
118
+ write-only `File`, which cannot be read through, is read by its name.
119
+ Other IO — a `StringIO`, a pipe, a socket — is read from its current
120
+ position through `#read(n)` and left where the read stopped.
121
+
122
+ The gem does that reading itself, in Ruby, between calls into the
123
+ library, so `#read` behaves as it would anywhere: whatever it raises
124
+ comes out of `probe` unchanged, and a `Timeout.timeout` or a
125
+ `Thread#kill` lands in it as usual.
126
+
127
+ With the probe library, `probe` runs in-process: oximg's own probe,
128
+ compiled into a small library (`ext/`) the gem calls through
129
+ [`ffi`](https://github.com/ffi/ffi). It reads only as much of the source
130
+ as the header takes, typically the first 64 KiB, and costs microseconds
131
+ where a spawn costs milliseconds. `resize` is not affected and still
132
+ runs in a child process, which keeps a full decode's memory and crashes
133
+ out of your process.
134
+
135
+ The platform gems bundle the library beside the executable (x86_64 and
136
+ aarch64 Linux, glibc and musl, and arm64 macOS), so they also depend
137
+ on `ffi`; beyond that it needs only libc and libgcc_s, which Ruby
138
+ itself needs, and not libstdc++.
139
+ `OXIMG_PROBE_LIB` points at another one; like `OXIMG_BIN`, one that
140
+ does not load raises rather than falling back. To build it from this
141
+ checkout, run `rake compile` here: it leaves the library under the
142
+ repo's `target/oximg-probe/release/`.
143
+
144
+ Without the library — the plain-Ruby gem, or a bundled one that cannot
145
+ load — `probe` spawns `oximg probe` instead, copying an IO to a
146
+ tempfile first. The answer is the same; only the cost differs.
147
+
148
+ ```ruby
149
+ Oximg.in_process_probe? #=> true on a platform gem
150
+ ```
151
+
62
152
  ## Where the binary comes from
63
153
 
64
154
  `Oximg.executable` resolves, first hit wins:
@@ -71,14 +161,14 @@ knob is a startup error, never a silent default.
71
161
 
72
162
  ```ruby
73
163
  Oximg.available? #=> true
74
- Oximg.executable #=> "/…/gems/oximg-0.16.0-arm64-darwin/exe/oximg"
75
- Oximg.version #=> "0.16.0" # the binary's version, not the gem's
164
+ Oximg.executable #=> "/…/gems/oximg-0.18.1-arm64-darwin/exe/oximg"
165
+ Oximg.version #=> "0.18.1" # the binary's version, not the gem's
76
166
  ```
77
167
 
78
- The gem shells out to that binary — one process per image, which is
168
+ `resize` shells out to that binary — one process per image, which is
79
169
  noise next to the encode itself and invisible inside the background job
80
- where variants are generated anyway. An in-process native extension is a
81
- later option; it would not change the API above.
170
+ where variants are generated anyway. Only `probe`, where the spawn was
171
+ the whole cost, runs in-process (see [Probing](#probing)).
82
172
 
83
173
  ## Rails
84
174
 
data/exe/oximg CHANGED
Binary file
data/lib/oximg/binary.rb CHANGED
@@ -60,8 +60,14 @@ module Oximg
60
60
  # nothing in a filename can be interpreted as syntax. Returns
61
61
  # [stdout, stderr]; a non-zero exit raises with the binary's own
62
62
  # stderr, which already names what it refused.
63
- def run(*args)
64
- out, err, status = Open3.capture3(path, *args)
63
+ #
64
+ # +env+ is merged into the child's environment only (a nil value
65
+ # unsets that name there): one process per call means one call's
66
+ # knobs never reach another's, even across threads, and the
67
+ # parent's ENV is never touched. Validate it first; Oximg.resize
68
+ # and Oximg.strip do.
69
+ def run(*args, env: {})
70
+ out, err, status = Open3.capture3(env, path, *args)
65
71
  unless status.success?
66
72
  message = err.strip
67
73
  message = "oximg exited #{status.exitstatus}" if message.empty?
Binary file
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Oximg
6
+ # The in-process header read: oximg's own probe, compiled as a small
7
+ # C library (rubygem/oximg/ext) and called through the +ffi+ gem, so
8
+ # Oximg.probe costs microseconds instead of a process spawn.
9
+ #
10
+ # Only the header read runs in-process. It reads a bounded prefix of
11
+ # the source and decodes no pixels; resize stays a child process, for
12
+ # the memory and crash isolation a full decode wants.
13
+ #
14
+ # Resolution order, first hit wins:
15
+ #
16
+ # 1. +OXIMG_PROBE_LIB+ — an explicit path always wins, and a wrong
17
+ # one raises rather than falling back, as +OXIMG_BIN+ does.
18
+ # 2. the library bundled in this gem, beside this file — platform
19
+ # gems ship one. If it cannot load (no +ffi+ gem, a missing
20
+ # system library), probe falls back to spawning the executable:
21
+ # slower, same answer.
22
+ #
23
+ # The plain-Ruby gem ships no library and always spawns.
24
+ module Native
25
+ LIBRARY = "liboximg_probe.#{RbConfig::CONFIG["SOEXT"]}"
26
+
27
+ # Populated by the platform gems at package time; absent in the
28
+ # plain-Ruby gem.
29
+ BUNDLED = File.expand_path("native/#{LIBRARY}", __dir__)
30
+
31
+ # The library's oximg_probe_abi_version this code was written for.
32
+ ABI_VERSION = 1
33
+
34
+ # Statuses (ext/src/lib.rs).
35
+ OK = 0
36
+ READ = 3
37
+
38
+ # The byte count READ gives for "to the end".
39
+ READ_ALL = 2**64 - 1
40
+
41
+ # Room for an error message; the library cuts a longer one at a
42
+ # character boundary.
43
+ MESSAGE_BYTES = 1024
44
+
45
+ class << self
46
+ # True when the library is loaded and probe runs in-process.
47
+ def available?
48
+ !binding_module.nil?
49
+ end
50
+
51
+ # Header of the file at +path+ (already expanded).
52
+ def probe_path(path)
53
+ # Copied off the Ruby heap: the call runs without the GVL, while
54
+ # other threads may run GC.
55
+ bytes = FFI::MemoryPointer.from_string(path)
56
+ call { |dims, msg| binding_module.oximg_probe_path(bytes, path.bytesize, dims, msg, MESSAGE_BYTES) }
57
+ end
58
+
59
+ # Header of the open, readable regular +file+, read through its
60
+ # descriptor from the start with positioned reads: what it holds,
61
+ # whatever its name leads to by now, its position untouched.
62
+ def probe_file(file)
63
+ call { |dims, msg| binding_module.oximg_probe_fd(file.fileno, dims, msg, MESSAGE_BYTES) }
64
+ end
65
+
66
+ # Header of +io+, read from its current position through
67
+ # IO#read(n) — a pipe, a socket, a StringIO. Only what the header
68
+ # needs is read, and the IO is left where the read stopped.
69
+ #
70
+ # The library decides how much to read and the reading happens
71
+ # here, between calls, so the IO's code never runs under the
72
+ # library's frames: whatever it raises, a Timeout or a Thread#kill
73
+ # included, leaves the way it would leave any Ruby method.
74
+ def probe_io(io)
75
+ prefix = String.new(encoding: Encoding::BINARY)
76
+ eof = false
77
+ loop do
78
+ want = nil
79
+ result = call do |dims, msg|
80
+ status = binding_module.oximg_probe_step(prefix, prefix.bytesize, eof ? 1 : 0, dims, msg, MESSAGE_BYTES)
81
+ want = dims.read_uint64 if status == READ
82
+ status
83
+ end
84
+ return result unless want
85
+
86
+ eof = read_to(io, prefix, want)
87
+ end
88
+ end
89
+
90
+ # Forgets the loaded library. For tests, and for a process that
91
+ # changes OXIMG_PROBE_LIB after boot.
92
+ def reset!
93
+ remove_instance_variable(:@binding_module) if instance_variable_defined?(:@binding_module)
94
+ end
95
+
96
+ private
97
+
98
+ # Reads +io+ onto +prefix+ until it holds +want+ bytes; true if the
99
+ # source ended first. READ_ALL asks for everything, which is read
100
+ # a MiB at a time: a length that large would be allocated up
101
+ # front, and an IO need only answer #read(n).
102
+ def read_to(io, prefix, want)
103
+ while want == READ_ALL || prefix.bytesize < want
104
+ chunk = io.read(want == READ_ALL ? 1 << 20 : want - prefix.bytesize)
105
+ return true if chunk.nil? || chunk.empty?
106
+
107
+ prefix << chunk.b
108
+ end
109
+ false
110
+ end
111
+
112
+ def call
113
+ dims = FFI::MemoryPointer.new(:uint64, 2)
114
+ msg = FFI::MemoryPointer.new(:char, MESSAGE_BYTES)
115
+ status = yield(dims, msg)
116
+ return if status == READ
117
+
118
+ text = msg.read_string.force_encoding(Encoding::UTF_8)
119
+ raise ProcessingError.new(text, 1) unless status == OK
120
+
121
+ width, height = dims.read_array_of_uint64(2)
122
+ {content_type: text, format: CONTENT_TYPE_FORMATS[text], width: width, height: height}
123
+ end
124
+
125
+ # nil when there is no library to use. Memoized either way, so a
126
+ # gem without one does not look again on every probe.
127
+ def binding_module
128
+ return @binding_module if instance_variable_defined?(:@binding_module)
129
+
130
+ @binding_module = load_library
131
+ end
132
+
133
+ def load_library
134
+ configured = ENV["OXIMG_PROBE_LIB"]
135
+ if configured.nil? || configured.strip.empty?
136
+ return nil unless File.file?(BUNDLED)
137
+
138
+ begin
139
+ open_library(BUNDLED)
140
+ rescue LoadError
141
+ nil
142
+ end
143
+ else
144
+ unless File.file?(configured)
145
+ raise LoadError, "OXIMG_PROBE_LIB is set to #{configured.inspect}, which is not a file"
146
+ end
147
+
148
+ # Absolute, so the file loaded is the one just checked. Where
149
+ # dlopen looks for a bare name depends on the platform (glibc
150
+ # never looks in the working directory; dyld does, after other
151
+ # places). absolute_path, unlike expand_path, leaves "~" alone,
152
+ # as File.file? did.
153
+ open_library(File.absolute_path(configured))
154
+ end
155
+ end
156
+
157
+ def open_library(path)
158
+ require "ffi"
159
+ mod = Module.new do
160
+ extend FFI::Library
161
+ ffi_lib path
162
+ attach_function :oximg_probe_abi_version, [], :uint32
163
+ # A path or descriptor read blocks on the filesystem, so it runs
164
+ # without the GVL. A step only parses bytes already in memory and
165
+ # keeps it, which also keeps the prefix String where it is.
166
+ attach_function :oximg_probe_path, %i[pointer size_t pointer pointer size_t], :int, blocking: true
167
+ attach_function :oximg_probe_fd, %i[int pointer pointer size_t], :int, blocking: true
168
+ attach_function :oximg_probe_step, %i[buffer_in size_t uint8 pointer pointer size_t], :int
169
+ end
170
+ abi = mod.oximg_probe_abi_version
171
+ unless abi == ABI_VERSION
172
+ raise LoadError, "#{path} speaks probe ABI #{abi}; this gem speaks #{ABI_VERSION}"
173
+ end
174
+
175
+ mod
176
+ end
177
+ end
178
+ end
179
+ end
data/lib/oximg/version.rb CHANGED
@@ -7,5 +7,5 @@ module Oximg
7
7
  # names a binary it does not carry. `Oximg.version` reports what the
8
8
  # resolved executable actually is, which can differ when it comes
9
9
  # from PATH.
10
- VERSION = "0.17.0"
10
+ VERSION = "0.18.1"
11
11
  end
data/lib/oximg.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fcntl"
4
+ require "tempfile"
3
5
  require_relative "oximg/version"
4
6
  require_relative "oximg/binary"
7
+ require_relative "oximg/native"
5
8
 
6
9
  # Ruby bindings for oximg (https://github.com/oximg/oximg):
7
10
  # high-performance image compression and resizing, in-process work for a
@@ -9,6 +12,7 @@ require_relative "oximg/binary"
9
12
  #
10
13
  # Oximg.resize("in.jpg", "out.jpg", width: 750)
11
14
  # Oximg.resize("in.jpg", "out.webp", width: 750, height: 500, quality: 80)
15
+ # Oximg.strip("upload.jpg", "clean.jpg") # drop EXIF/GPS, XMP, IPTC; no re-encode
12
16
  # Oximg.probe("in.jpg") #=> {content_type: "image/jpeg", format: :jpeg, ...}
13
17
  #
14
18
  # Rails/ActiveStorage integration, and URL building for a remote oximg
@@ -41,6 +45,52 @@ module Oximg
41
45
  # mozjpeg's fast/small trade differently.
42
46
  PRESETS = %i[jpegli fast small].freeze
43
47
 
48
+ # What `resize` and `strip` may do with an embedded colour profile.
49
+ ICC_MODES = %i[keep strip].freeze
50
+
51
+ # The OXIMG_* pipeline knobs an +env:+ may set for one call. Pinned to
52
+ # the binary's own inventory (KNOBS in src/config.rs) by a test there,
53
+ # so a knob the binary gains or drops cannot drift from this list.
54
+ # Server-startup settings (OXIMG_KEY, OXIMG_BIND, ...) are not here:
55
+ # a one-shot never reads them.
56
+ KNOBS = %w[
57
+ OXIMG_TIMING
58
+ OXIMG_RESIZE
59
+ OXIMG_RESIZE_BACKEND
60
+ OXIMG_AUTO_ROTATE
61
+ OXIMG_ICC
62
+ OXIMG_DCT_MARGIN
63
+ OXIMG_LINEAR_SHRINK
64
+ OXIMG_JPEG_PROGRESSIVE
65
+ OXIMG_JPEG_SEQUENTIAL_PIXELS
66
+ OXIMG_JPEG_STREAMING_PIXELS
67
+ OXIMG_FLATTEN_BG
68
+ OXIMG_PNG_EFFORT
69
+ OXIMG_PNG_QUANTIZE
70
+ OXIMG_PNG_QUANTIZE_COLORS
71
+ OXIMG_WEBP_QUALITY
72
+ OXIMG_WEBP_EFFORT
73
+ OXIMG_WEBP_DECODE_THREADS
74
+ OXIMG_AVIF_QUALITY
75
+ OXIMG_AVIF_ALPHA_QUALITY
76
+ OXIMG_AVIF_SPEED
77
+ OXIMG_AVIF_DECODE_THREADS
78
+ OXIMG_MAX_SOURCE_BYTES
79
+ OXIMG_MAX_SRC_PIXELS
80
+ OXIMG_GIF_ANIMATION
81
+ OXIMG_MAX_ANIM_FRAMES
82
+ OXIMG_MAX_ANIM_WORK
83
+ OXIMG_ANIM_FRAME_STEP
84
+ OXIMG_MAX_DECODED_BYTES
85
+ OXIMG_LOG_DECODED_BYTES_ABOVE
86
+ OXIMG_UPSTREAM_CONNECT_TIMEOUT
87
+ OXIMG_UPSTREAM_TIMEOUT
88
+ OXIMG_GCS_ENDPOINT
89
+ OXIMG_S3_ENDPOINT
90
+ OXIMG_S3_PATH_STYLE
91
+ OXIMG_OVERLAP
92
+ ].freeze
93
+
44
94
  CONTENT_TYPE_FORMATS = {
45
95
  "image/jpeg" => :jpeg,
46
96
  "image/png" => :png,
@@ -99,25 +149,121 @@ module Oximg
99
149
  # The output format follows +format:+, else +destination+'s
100
150
  # extension, else the source's own format. Returns the destination
101
151
  # path.
102
- def resize(source, destination, width: 0, height: 0, quality: nil, format: nil, preset: nil)
152
+ #
153
+ # +icc:+ (:keep or :strip) and +auto_rotate:+ (true or false)
154
+ # override the binary's OXIMG_ICC and OXIMG_AUTO_ROTATE for this
155
+ # call only; nil leaves them to the environment (default: keep the
156
+ # profile, apply the EXIF orientation).
157
+ #
158
+ # +env:+ sets any other pipeline knob for this call only, e.g.
159
+ # <tt>env: {"OXIMG_JPEG_PROGRESSIVE" => "0"}</tt>: it reaches the
160
+ # one child process this call spawns, never ENV, so it is safe in a
161
+ # threaded job runner. Names must be in KNOBS; nil unsets a name
162
+ # for the call. The binary validates the values, fail-closed.
163
+ def resize(source, destination, width: 0, height: 0, quality: nil, format: nil, preset: nil,
164
+ icc: nil, auto_rotate: nil, env: nil)
165
+ output = expand(destination, "destination")
166
+ argv = resize_argv(source, output,
167
+ width: width, height: height, quality: quality, format: format, preset: preset,
168
+ icc: icc, auto_rotate: auto_rotate)
169
+ Binary.run(*argv, env: knob_env(env))
170
+ output
171
+ end
172
+
173
+ # Removes metadata — EXIF (GPS, camera, timestamps), XMP, IPTC,
174
+ # comments — from a JPEG, PNG or WebP without re-encoding, and
175
+ # writes the result to +destination+. Decoded pixels are identical;
176
+ # a rotated photo keeps only its EXIF orientation, so it still
177
+ # displays upright. +icc:+ is :keep or :strip (default: the
178
+ # binary's OXIMG_ICC, else keep); dropping a CMYK JPEG's profile is
179
+ # refused with a ProcessingError. Returns the destination path.
180
+ #
181
+ # This is what <tt>saver(strip: true)</tt> / <tt>-strip</tt> did
182
+ # under libvips or ImageMagick, minus the re-encode.
183
+ #
184
+ # +env:+ is per-call knobs, as for resize.
185
+ def strip(source, destination, icc: nil, env: nil)
103
186
  output = expand(destination, "destination")
104
- Binary.run(*resize_argv(source, output,
105
- width: width, height: height, quality: quality, format: format, preset: preset))
187
+ argv = strip_argv(source, output, icc: icc)
188
+ Binary.run(*argv, env: knob_env(env))
106
189
  output
107
190
  end
108
191
 
192
+ # The child environment an +env:+ turns into: String names and
193
+ # values, each name a known knob. A typo raises here rather than
194
+ # being passed along and ignored, which is the same fail-closed
195
+ # promise the binary makes about the values. Public for the same
196
+ # reason as resize_argv.
197
+ def knob_env(env)
198
+ return {} if env.nil?
199
+ raise ArgumentError, "env must be a Hash, got #{env.inspect}" unless env.is_a?(Hash)
200
+
201
+ env.each_with_object({}) do |(name, value), out|
202
+ key = name.to_s
203
+ unless KNOBS.include?(key)
204
+ raise ArgumentError, "unknown knob #{name.inspect} in env (one of Oximg::KNOBS)"
205
+ end
206
+ unless value.nil? || value.is_a?(String) || value.is_a?(Integer)
207
+ raise ArgumentError, "#{key} must be a String, an Integer or nil, got #{value.inspect}"
208
+ end
209
+
210
+ out[key] = value&.to_s
211
+ end
212
+ end
213
+
214
+ # The argv `strip` would run, minus the executable; public for the
215
+ # same reason as resize_argv.
216
+ def strip_argv(source, destination, icc: nil)
217
+ argv = ["strip", expand(source, "source"), expand(destination, "destination")]
218
+ argv.push("--icc", token(icc, ICC_MODES, "icc")) unless icc.nil?
219
+ argv
220
+ end
221
+
109
222
  # Header-only inspection — no pixels are decoded. Returns
110
223
  # <tt>{content_type:, format:, width:, height:}</tt>, where the
111
224
  # dimensions are the stored ones (an EXIF rotation is not applied).
225
+ #
226
+ # +source+ is a path — a String, a Pathname, or anything with #path
227
+ # such as a Tempfile — an open File, or a stream read from its
228
+ # current position through #read(n): a StringIO, a pipe, a socket.
229
+ # A String is always a path, so bytes already in memory go in as
230
+ # <tt>StringIO.new(bytes)</tt>. An open File on a regular file is
231
+ # read from its start, its position untouched, and it is the file
232
+ # it holds that is read, whatever its name leads to by now. A closed
233
+ # or write-only File, which cannot be read through, is read by its
234
+ # name.
235
+ #
236
+ # With the probe library this runs in-process and reads only as much
237
+ # of the source as the header takes (see in_process_probe?). Without it,
238
+ # it spawns <tt>oximg probe</tt>, which reads the whole source, and an
239
+ # IO is copied to a tempfile first. The answer is the same.
112
240
  def probe(source)
113
- out, = Binary.run("probe", expand(source, "source"))
241
+ return probe_open_file(source) if open_regular_file?(source)
242
+
243
+ if stream?(source)
244
+ return Native.available? ? Native.probe_io(source) : probe_spawned_io(source)
245
+ end
246
+
247
+ path = expand(source, "source")
248
+ return Native.probe_path(path) if Native.available?
249
+
250
+ out, = Binary.run("probe", path)
114
251
  parse_probe(out)
115
252
  end
116
253
 
254
+ # True when probe runs in-process, through the probe library (see
255
+ # Native); false when it spawns the executable. Either way
256
+ # probe answers the same, and only its cost differs — this is for a
257
+ # boot-time log, or a test that must know which path it took.
258
+ def in_process_probe?
259
+ Native.available?
260
+ end
261
+
117
262
  # The argv `resize` would run, minus the executable. Public because
118
263
  # it is the honest way to see what a call turns into — and the way
119
264
  # to test that without a binary present.
120
- def resize_argv(source, destination, width: 0, height: 0, quality: nil, format: nil, preset: nil)
265
+ def resize_argv(source, destination, width: 0, height: 0, quality: nil, format: nil, preset: nil,
266
+ icc: nil, auto_rotate: nil)
121
267
  argv = [
122
268
  "resize",
123
269
  expand(source, "source"),
@@ -128,11 +274,87 @@ module Oximg
128
274
  argv.push("-q", bounded(quality, "quality", 1, 100).to_s) unless quality.nil?
129
275
  argv.push("-f", token(format, FORMATS, "format")) unless format.nil?
130
276
  argv.push("--preset", token(preset, PRESETS, "preset")) unless preset.nil?
277
+ argv.push("--icc", token(icc, ICC_MODES, "icc")) unless icc.nil?
278
+ argv.push(boolean(auto_rotate, "auto_rotate") ? "--auto-rotate" : "--no-auto-rotate") unless auto_rotate.nil?
131
279
  argv
132
280
  end
133
281
 
134
282
  private
135
283
 
284
+ def open_regular_file?(source)
285
+ source.is_a?(File) && !source.closed? && source.stat.file?
286
+ end
287
+
288
+ # An open File is read through its descriptor, not by the name it
289
+ # was opened under: the name may lead to another file by now
290
+ # (replaced, renamed, or relative to a working directory the
291
+ # process has since left). Positioned reads leave its position
292
+ # alone. A write-only File cannot be read that way, and is read by
293
+ # its name.
294
+ def probe_open_file(file)
295
+ readable = (file.fcntl(Fcntl::F_GETFL) & Fcntl::O_ACCMODE) != Fcntl::O_WRONLY
296
+ return Native.probe_file(file) if readable && Native.available?
297
+
298
+ # A File made from a descriptor (File.for_fd) has no name.
299
+ name = file.path.to_s.empty? ? nil : File.expand_path(file.path)
300
+ if name && (!readable || File.identical?(file, name))
301
+ return Native.probe_path(name) if Native.available?
302
+
303
+ out, = Binary.run("probe", name)
304
+ return parse_probe(out)
305
+ end
306
+ raise IOError, "a write-only File with no name cannot be probed" unless readable
307
+
308
+ # The executable reads names only, and this one has moved on: it
309
+ # gets a copy, made with positioned reads.
310
+ Tempfile.create("oximg-probe") do |copy|
311
+ copy.binmode
312
+ offset = 0
313
+ loop do
314
+ chunk = file.pread(1 << 20, offset)
315
+ copy.write(chunk)
316
+ offset += chunk.bytesize
317
+ rescue EOFError
318
+ break
319
+ end
320
+ copy.flush
321
+ out, = Binary.run("probe", copy.path)
322
+ parse_probe(out)
323
+ end
324
+ end
325
+
326
+ # Whether probe reads +source+ through #read rather than opening it
327
+ # by name. Every IO answers #path since Ruby 3.2 — nil for a pipe,
328
+ # "" for a socket, "<STDIN>" for STDIN — so for an IO that proves
329
+ # nothing: an IO is a stream, except a closed File, which has
330
+ # nothing left to read but its name (an open one on a regular file
331
+ # never gets here). Anything else keeps the duck typing: #to_path
332
+ # or #path (a Pathname, a Tempfile, an upload) is a path.
333
+ def stream?(source)
334
+ case source
335
+ when String then false
336
+ when File then !source.closed?
337
+ when IO then true
338
+ else !source.respond_to?(:to_path) && !source.respond_to?(:path) && source.respond_to?(:read)
339
+ end
340
+ end
341
+
342
+ # The executable reads paths only, so without the library an IO
343
+ # goes through a tempfile. It is read through #read(n) alone, as the
344
+ # library reads it, so probe takes the same IO either way
345
+ # (IO.copy_stream would also pass #read a buffer).
346
+ def probe_spawned_io(io)
347
+ Tempfile.create("oximg-probe") do |file|
348
+ file.binmode
349
+ while (chunk = io.read(1 << 20)) && !chunk.empty?
350
+ file.write(chunk)
351
+ end
352
+ file.flush
353
+ out, = Binary.run("probe", file.path)
354
+ parse_probe(out)
355
+ end
356
+ end
357
+
136
358
  # The hash `probe` builds from the line the CLI printed. Separate
137
359
  # from `probe` so the parser can be tested against every line the
138
360
  # CLI can print — the still form, each loop spelling — without a
@@ -153,7 +375,11 @@ module Oximg
153
375
  # would otherwise reach the CLI's argument parser as a flag, and
154
376
  # the caller rarely controls what an uploaded file is called.
155
377
  def expand(path, name)
156
- path = path.path if path.respond_to?(:path)
378
+ if path.respond_to?(:path)
379
+ path = path.path
380
+ elsif path.respond_to?(:to_path)
381
+ path = path.to_path
382
+ end
157
383
  path = path.to_s
158
384
  raise ArgumentError, "#{name} path is empty" if path.strip.empty?
159
385
 
@@ -178,6 +404,16 @@ module Oximg
178
404
  value
179
405
  end
180
406
 
407
+ # Strictly true or false: a truthy "0" or "false" read as true would
408
+ # rotate exactly the images the caller asked not to.
409
+ def boolean(value, name)
410
+ unless value == true || value == false
411
+ raise ArgumentError, "#{name} must be true or false, got #{value.inspect}"
412
+ end
413
+
414
+ value
415
+ end
416
+
181
417
  def token(value, allowed, name)
182
418
  token = value.to_s.downcase
183
419
  unless allowed.include?(token.to_sym)
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oximg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.18.1
5
5
  platform: aarch64-linux-gnu
6
6
  authors:
7
7
  - oximg contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.17'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.17'
12
26
  description: 'Ruby bindings for oximg, the high-performance Rust image compression
13
27
  and resizing engine: JPEG, PNG, WebP and AVIF, resized in linear light. Platform
14
28
  gems bundle the executable, so there is no system library to install.'
@@ -21,6 +35,8 @@ files:
21
35
  - exe/oximg
22
36
  - lib/oximg.rb
23
37
  - lib/oximg/binary.rb
38
+ - lib/oximg/native.rb
39
+ - lib/oximg/native/liboximg_probe.so
24
40
  - lib/oximg/version.rb
25
41
  homepage: https://github.com/oximg/oximg
26
42
  licenses: