oximg 0.17.0 → 0.18.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/README.md +72 -5
- data/lib/oximg/native.rb +179 -0
- data/lib/oximg/version.rb +1 -1
- data/lib/oximg.rb +143 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16e1f0079f0e556b9dd2bf4200f3e5667bb6904d1ab6597a7189dee077c731bc
|
|
4
|
+
data.tar.gz: cf19c0196c9636544db5c4318170b262a6ba9d57529776ef7fd0b13faa77e521
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c0cf987fde6720add95242c3c5e12a21f395b27c2c32cc7ab8e52c9203d02162a3823315a68d4bb69719c13d831573256b87d7acf2e140fe6d97c30f18d46e30
|
|
7
|
+
data.tar.gz: a8d0316e4b3770e02c1f4347d6f9e514ba24a85b02903ea43b12a3ef01cbd875c851bbe2fc2ad634493ae601be810cf918d40407e3065d591da8562c57394c88
|
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
|
```
|
|
@@ -55,10 +56,76 @@ on failure, carrying the binary's own message and exit status. Paths are
|
|
|
55
56
|
expanded before they reach the CLI, so an uploaded file called `-q.jpg`
|
|
56
57
|
cannot be read as a flag.
|
|
57
58
|
|
|
59
|
+
## Stripping metadata
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Oximg.strip(source, destination, icc: nil)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Removes EXIF (GPS, camera, timestamps), XMP, IPTC and comments from a
|
|
66
|
+
JPEG, PNG or WebP **without re-encoding**. Decoded pixels are identical,
|
|
67
|
+
and it costs a fraction of a `0 x 0` resize in time and memory. This
|
|
68
|
+
replaces `saver(strip: true)` (libvips) or `-strip` (ImageMagick). A
|
|
69
|
+
rotated photo keeps an EXIF block holding only its orientation, so it
|
|
70
|
+
still displays upright. `icc: :strip` also drops the colour profile.
|
|
71
|
+
The default is `:keep`, or `OXIMG_ICC`. Dropping a CMYK JPEG's profile
|
|
72
|
+
raises `Oximg::ProcessingError`, as do GIF and AVIF sources.
|
|
73
|
+
|
|
58
74
|
Everything the server tunes through `OXIMG_*` environment variables
|
|
59
75
|
applies here too, and is validated the same fail-closed way: a typo'd
|
|
60
76
|
knob is a startup error, never a silent default.
|
|
61
77
|
|
|
78
|
+
## Probing
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
Oximg.probe("in.jpg") # a path
|
|
82
|
+
Oximg.probe(Pathname("in.jpg")) # also a path
|
|
83
|
+
Oximg.probe(upload.tempfile) # anything with #path
|
|
84
|
+
Oximg.probe(File.open("in.jpg", "rb")) # an open File
|
|
85
|
+
Oximg.probe(StringIO.new(bytes)) # an IO: bytes in memory
|
|
86
|
+
#=> {content_type: "image/jpeg", format: :jpeg, width: 4000, height: 3000}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`probe` reads the header only and decodes no pixels. The dimensions are
|
|
90
|
+
the stored ones, before any EXIF rotation. A String is always a path,
|
|
91
|
+
so bytes already in memory go in as a `StringIO`. An open `File` is
|
|
92
|
+
read from its start, its position untouched, and it is the file you
|
|
93
|
+
hold that is read, even if its name has since been replaced, renamed,
|
|
94
|
+
or was relative to a directory the process has left. A closed or
|
|
95
|
+
write-only `File`, which cannot be read through, is read by its name.
|
|
96
|
+
Other IO — a `StringIO`, a pipe, a socket — is read from its current
|
|
97
|
+
position through `#read(n)` and left where the read stopped.
|
|
98
|
+
|
|
99
|
+
The gem does that reading itself, in Ruby, between calls into the
|
|
100
|
+
library, so `#read` behaves as it would anywhere: whatever it raises
|
|
101
|
+
comes out of `probe` unchanged, and a `Timeout.timeout` or a
|
|
102
|
+
`Thread#kill` lands in it as usual.
|
|
103
|
+
|
|
104
|
+
With the probe library, `probe` runs in-process: oximg's own probe,
|
|
105
|
+
compiled into a small library (`ext/`) the gem calls through
|
|
106
|
+
[`ffi`](https://github.com/ffi/ffi). It reads only as much of the source
|
|
107
|
+
as the header takes, typically the first 64 KiB, and costs microseconds
|
|
108
|
+
where a spawn costs milliseconds. `resize` is not affected and still
|
|
109
|
+
runs in a child process, which keeps a full decode's memory and crashes
|
|
110
|
+
out of your process.
|
|
111
|
+
|
|
112
|
+
The platform gems bundle the library beside the executable (x86_64 and
|
|
113
|
+
aarch64 Linux, glibc and musl, and arm64 macOS), so they also depend
|
|
114
|
+
on `ffi`; beyond that it needs only libc and libgcc_s, which Ruby
|
|
115
|
+
itself needs, and not libstdc++.
|
|
116
|
+
`OXIMG_PROBE_LIB` points at another one; like `OXIMG_BIN`, one that
|
|
117
|
+
does not load raises rather than falling back. To build it from this
|
|
118
|
+
checkout, run `rake compile` here: it leaves the library under the
|
|
119
|
+
repo's `target/oximg-probe/release/`.
|
|
120
|
+
|
|
121
|
+
Without the library — the plain-Ruby gem, or a bundled one that cannot
|
|
122
|
+
load — `probe` spawns `oximg probe` instead, copying an IO to a
|
|
123
|
+
tempfile first. The answer is the same; only the cost differs.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
Oximg.in_process_probe? #=> true on a platform gem
|
|
127
|
+
```
|
|
128
|
+
|
|
62
129
|
## Where the binary comes from
|
|
63
130
|
|
|
64
131
|
`Oximg.executable` resolves, first hit wins:
|
|
@@ -71,14 +138,14 @@ knob is a startup error, never a silent default.
|
|
|
71
138
|
|
|
72
139
|
```ruby
|
|
73
140
|
Oximg.available? #=> true
|
|
74
|
-
Oximg.executable #=> "/…/gems/oximg-0.
|
|
75
|
-
Oximg.version #=> "0.
|
|
141
|
+
Oximg.executable #=> "/…/gems/oximg-0.18.0-arm64-darwin/exe/oximg"
|
|
142
|
+
Oximg.version #=> "0.18.0" # the binary's version, not the gem's
|
|
76
143
|
```
|
|
77
144
|
|
|
78
|
-
|
|
145
|
+
`resize` shells out to that binary — one process per image, which is
|
|
79
146
|
noise next to the encode itself and invisible inside the background job
|
|
80
|
-
where variants are generated anyway.
|
|
81
|
-
|
|
147
|
+
where variants are generated anyway. Only `probe`, where the spawn was
|
|
148
|
+
the whole cost, runs in-process (see [Probing](#probing)).
|
|
82
149
|
|
|
83
150
|
## Rails
|
|
84
151
|
|
data/lib/oximg/native.rb
ADDED
|
@@ -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
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,9 @@ module Oximg
|
|
|
41
45
|
# mozjpeg's fast/small trade differently.
|
|
42
46
|
PRESETS = %i[jpegli fast small].freeze
|
|
43
47
|
|
|
48
|
+
# What `strip` may do with an embedded colour profile.
|
|
49
|
+
ICC_MODES = %i[keep strip].freeze
|
|
50
|
+
|
|
44
51
|
CONTENT_TYPE_FORMATS = {
|
|
45
52
|
"image/jpeg" => :jpeg,
|
|
46
53
|
"image/png" => :png,
|
|
@@ -106,14 +113,70 @@ module Oximg
|
|
|
106
113
|
output
|
|
107
114
|
end
|
|
108
115
|
|
|
116
|
+
# Removes metadata — EXIF (GPS, camera, timestamps), XMP, IPTC,
|
|
117
|
+
# comments — from a JPEG, PNG or WebP without re-encoding, and
|
|
118
|
+
# writes the result to +destination+. Decoded pixels are identical;
|
|
119
|
+
# a rotated photo keeps only its EXIF orientation, so it still
|
|
120
|
+
# displays upright. +icc:+ is :keep or :strip (default: the
|
|
121
|
+
# binary's OXIMG_ICC, else keep); dropping a CMYK JPEG's profile is
|
|
122
|
+
# refused with a ProcessingError. Returns the destination path.
|
|
123
|
+
#
|
|
124
|
+
# This is what <tt>saver(strip: true)</tt> / <tt>-strip</tt> did
|
|
125
|
+
# under libvips or ImageMagick, minus the re-encode.
|
|
126
|
+
def strip(source, destination, icc: nil)
|
|
127
|
+
output = expand(destination, "destination")
|
|
128
|
+
Binary.run(*strip_argv(source, output, icc: icc))
|
|
129
|
+
output
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The argv `strip` would run, minus the executable; public for the
|
|
133
|
+
# same reason as resize_argv.
|
|
134
|
+
def strip_argv(source, destination, icc: nil)
|
|
135
|
+
argv = ["strip", expand(source, "source"), expand(destination, "destination")]
|
|
136
|
+
argv.push("--icc", token(icc, ICC_MODES, "icc")) unless icc.nil?
|
|
137
|
+
argv
|
|
138
|
+
end
|
|
139
|
+
|
|
109
140
|
# Header-only inspection — no pixels are decoded. Returns
|
|
110
141
|
# <tt>{content_type:, format:, width:, height:}</tt>, where the
|
|
111
142
|
# dimensions are the stored ones (an EXIF rotation is not applied).
|
|
143
|
+
#
|
|
144
|
+
# +source+ is a path — a String, a Pathname, or anything with #path
|
|
145
|
+
# such as a Tempfile — an open File, or a stream read from its
|
|
146
|
+
# current position through #read(n): a StringIO, a pipe, a socket.
|
|
147
|
+
# A String is always a path, so bytes already in memory go in as
|
|
148
|
+
# <tt>StringIO.new(bytes)</tt>. An open File on a regular file is
|
|
149
|
+
# read from its start, its position untouched, and it is the file
|
|
150
|
+
# it holds that is read, whatever its name leads to by now. A closed
|
|
151
|
+
# or write-only File, which cannot be read through, is read by its
|
|
152
|
+
# name.
|
|
153
|
+
#
|
|
154
|
+
# With the probe library this runs in-process and reads only as much
|
|
155
|
+
# of the source as the header takes (see in_process_probe?). Without it,
|
|
156
|
+
# it spawns <tt>oximg probe</tt>, which reads the whole source, and an
|
|
157
|
+
# IO is copied to a tempfile first. The answer is the same.
|
|
112
158
|
def probe(source)
|
|
113
|
-
|
|
159
|
+
return probe_open_file(source) if open_regular_file?(source)
|
|
160
|
+
|
|
161
|
+
if stream?(source)
|
|
162
|
+
return Native.available? ? Native.probe_io(source) : probe_spawned_io(source)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
path = expand(source, "source")
|
|
166
|
+
return Native.probe_path(path) if Native.available?
|
|
167
|
+
|
|
168
|
+
out, = Binary.run("probe", path)
|
|
114
169
|
parse_probe(out)
|
|
115
170
|
end
|
|
116
171
|
|
|
172
|
+
# True when probe runs in-process, through the probe library (see
|
|
173
|
+
# Native); false when it spawns the executable. Either way
|
|
174
|
+
# probe answers the same, and only its cost differs — this is for a
|
|
175
|
+
# boot-time log, or a test that must know which path it took.
|
|
176
|
+
def in_process_probe?
|
|
177
|
+
Native.available?
|
|
178
|
+
end
|
|
179
|
+
|
|
117
180
|
# The argv `resize` would run, minus the executable. Public because
|
|
118
181
|
# it is the honest way to see what a call turns into — and the way
|
|
119
182
|
# to test that without a binary present.
|
|
@@ -133,6 +196,80 @@ module Oximg
|
|
|
133
196
|
|
|
134
197
|
private
|
|
135
198
|
|
|
199
|
+
def open_regular_file?(source)
|
|
200
|
+
source.is_a?(File) && !source.closed? && source.stat.file?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# An open File is read through its descriptor, not by the name it
|
|
204
|
+
# was opened under: the name may lead to another file by now
|
|
205
|
+
# (replaced, renamed, or relative to a working directory the
|
|
206
|
+
# process has since left). Positioned reads leave its position
|
|
207
|
+
# alone. A write-only File cannot be read that way, and is read by
|
|
208
|
+
# its name.
|
|
209
|
+
def probe_open_file(file)
|
|
210
|
+
readable = (file.fcntl(Fcntl::F_GETFL) & Fcntl::O_ACCMODE) != Fcntl::O_WRONLY
|
|
211
|
+
return Native.probe_file(file) if readable && Native.available?
|
|
212
|
+
|
|
213
|
+
# A File made from a descriptor (File.for_fd) has no name.
|
|
214
|
+
name = file.path.to_s.empty? ? nil : File.expand_path(file.path)
|
|
215
|
+
if name && (!readable || File.identical?(file, name))
|
|
216
|
+
return Native.probe_path(name) if Native.available?
|
|
217
|
+
|
|
218
|
+
out, = Binary.run("probe", name)
|
|
219
|
+
return parse_probe(out)
|
|
220
|
+
end
|
|
221
|
+
raise IOError, "a write-only File with no name cannot be probed" unless readable
|
|
222
|
+
|
|
223
|
+
# The executable reads names only, and this one has moved on: it
|
|
224
|
+
# gets a copy, made with positioned reads.
|
|
225
|
+
Tempfile.create("oximg-probe") do |copy|
|
|
226
|
+
copy.binmode
|
|
227
|
+
offset = 0
|
|
228
|
+
loop do
|
|
229
|
+
chunk = file.pread(1 << 20, offset)
|
|
230
|
+
copy.write(chunk)
|
|
231
|
+
offset += chunk.bytesize
|
|
232
|
+
rescue EOFError
|
|
233
|
+
break
|
|
234
|
+
end
|
|
235
|
+
copy.flush
|
|
236
|
+
out, = Binary.run("probe", copy.path)
|
|
237
|
+
parse_probe(out)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Whether probe reads +source+ through #read rather than opening it
|
|
242
|
+
# by name. Every IO answers #path since Ruby 3.2 — nil for a pipe,
|
|
243
|
+
# "" for a socket, "<STDIN>" for STDIN — so for an IO that proves
|
|
244
|
+
# nothing: an IO is a stream, except a closed File, which has
|
|
245
|
+
# nothing left to read but its name (an open one on a regular file
|
|
246
|
+
# never gets here). Anything else keeps the duck typing: #to_path
|
|
247
|
+
# or #path (a Pathname, a Tempfile, an upload) is a path.
|
|
248
|
+
def stream?(source)
|
|
249
|
+
case source
|
|
250
|
+
when String then false
|
|
251
|
+
when File then !source.closed?
|
|
252
|
+
when IO then true
|
|
253
|
+
else !source.respond_to?(:to_path) && !source.respond_to?(:path) && source.respond_to?(:read)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The executable reads paths only, so without the library an IO
|
|
258
|
+
# goes through a tempfile. It is read through #read(n) alone, as the
|
|
259
|
+
# library reads it, so probe takes the same IO either way
|
|
260
|
+
# (IO.copy_stream would also pass #read a buffer).
|
|
261
|
+
def probe_spawned_io(io)
|
|
262
|
+
Tempfile.create("oximg-probe") do |file|
|
|
263
|
+
file.binmode
|
|
264
|
+
while (chunk = io.read(1 << 20)) && !chunk.empty?
|
|
265
|
+
file.write(chunk)
|
|
266
|
+
end
|
|
267
|
+
file.flush
|
|
268
|
+
out, = Binary.run("probe", file.path)
|
|
269
|
+
parse_probe(out)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
136
273
|
# The hash `probe` builds from the line the CLI printed. Separate
|
|
137
274
|
# from `probe` so the parser can be tested against every line the
|
|
138
275
|
# CLI can print — the still form, each loop spelling — without a
|
|
@@ -153,7 +290,11 @@ module Oximg
|
|
|
153
290
|
# would otherwise reach the CLI's argument parser as a flag, and
|
|
154
291
|
# the caller rarely controls what an uploaded file is called.
|
|
155
292
|
def expand(path, name)
|
|
156
|
-
|
|
293
|
+
if path.respond_to?(:path)
|
|
294
|
+
path = path.path
|
|
295
|
+
elsif path.respond_to?(:to_path)
|
|
296
|
+
path = path.to_path
|
|
297
|
+
end
|
|
157
298
|
path = path.to_s
|
|
158
299
|
raise ArgumentError, "#{name} path is empty" if path.strip.empty?
|
|
159
300
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: oximg
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- oximg contributors
|
|
@@ -20,6 +20,7 @@ files:
|
|
|
20
20
|
- README.md
|
|
21
21
|
- lib/oximg.rb
|
|
22
22
|
- lib/oximg/binary.rb
|
|
23
|
+
- lib/oximg/native.rb
|
|
23
24
|
- lib/oximg/version.rb
|
|
24
25
|
homepage: https://github.com/oximg/oximg
|
|
25
26
|
licenses:
|