From 0000474659e98f563880ceea053b102b3a5881cd Mon Sep 17 00:00:00 2001 From: Bogdan Gusiev Date: Sun, 9 Nov 2025 21:37:51 +0100 Subject: [PATCH] Add support for reading exif tags from IO objects - Updated `README.md` to include instructions for reading metadata from IO objects. - Modified `lib/exiftool.rb` to handle IO objects as input, allowing ExifTool to infer file type from content. - Utilized `Open3.popen3` for executing shell commands, ensuring proper process management and error handling. - Added a test case in `test/exiftool_test.rb`. --- README.md | 10 ++++++++ lib/exiftool.rb | 59 +++++++++++++++++++++++++++++++++++++------ test/exiftool_test.rb | 11 ++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cf3f603..3245879 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,16 @@ e.files_with_results # => ["path/to/iPhone 4S.jpg", "path/to/Droid X.jpg", … ``` +### Reading from IO + +You can pass an IO object to read metadata from standard input. ExifTool will infer the file type from content: + +```ruby +io = File.open('test/IMG_2452.jpg', 'rb') +e = Exiftool.new(io) +e[:make] # => "Canon" +``` + ### Dates without timezones It seems that most exif dates don't include timezone offsets, without which forces us to assume the diff --git a/lib/exiftool.rb b/lib/exiftool.rb index dc3b54f..60c8eb8 100644 --- a/lib/exiftool.rb +++ b/lib/exiftool.rb @@ -2,6 +2,7 @@ require 'json' require 'shellwords' +require 'open3' require 'exiftool/result' require 'forwardable' require 'pathname' @@ -30,7 +31,20 @@ def self.exiftool_installed? # This is a string, not a float, to handle versions like "9.40" properly. def self.exiftool_version - @exiftool_version ||= `#{command} -ver 2> /dev/null`.chomp + return @exiftool_version if defined?(@exiftool_version) && @exiftool_version + + stdout_str = '' + begin + Open3.popen3(command, '-ver') do |_stdin, stdout, _stderr, wait_thr| + stdout_str = stdout.read.to_s.chomp + # Ensure the process is reaped + wait_thr.value + end + rescue Errno::ENOENT + stdout_str = '' + end + + @exiftool_version = stdout_str end def self.expand_path(filename) @@ -47,16 +61,45 @@ def self.expand_path(filename) def initialize(filenames, exiftool_opts = '') @file2result = {} + io_input = nil + if filenames.is_a?(IO) + io_input = filenames + filenames = ['-'] + end + filenames = [filenames] if filenames.is_a?(String) || filenames.is_a?(Pathname) return if filenames.empty? - escaped_filenames = filenames.map do |f| - Shellwords.escape(self.class.expand_path(f.to_s)) - end.join(' ') - # I'd like to use -dateformat, but it doesn't support timezone offsets properly, - # nor sub-second timestamps. - cmd = "#{self.class.command} #{exiftool_opts} -j -coordFormat \"%.8f\" #{escaped_filenames} 2> /dev/null" - json = `#{cmd}`.chomp + expanded_filenames = filenames.map do |f| + f == '-' ? '-' : self.class.expand_path(f.to_s) + end + args = [ + self.class.command, + *Shellwords.split(exiftool_opts), + '-j', + '-coordFormat', '%.8f', + *expanded_filenames + ] + + json = '' + begin + Open3.popen3(*args) do |stdin, stdout, _stderr, wait_thr| + if io_input + # Reading first 64KB. + # It is enough to parse exif tags. + # https://en.wikipedia.org/wiki/Exif#Technical_2 + while (chunk = io_input.read(1 << 16)) + stdin.write(chunk) + end + stdin.close + end + json = stdout.read.to_s.chomp + wait_thr.value + end + rescue Errno::ENOENT + json = '' + end + raise ExiftoolNotInstalled if json == '' JSON.parse(json).each do |raw| diff --git a/test/exiftool_test.rb b/test/exiftool_test.rb index ecd3076..977fb7b 100644 --- a/test/exiftool_test.rb +++ b/test/exiftool_test.rb @@ -80,6 +80,17 @@ validate_result(e, 'test/utf8.jpg') end + it 'supports an IO object as a constructor arg' do + File.open('test/IMG_2452.jpg', 'rb') do |io| + e = Exiftool.new(io) + _(e.errors?).must_be_false + h = e.to_hash + _(h[:file_type]).must_equal 'JPEG' + _(h[:mime_type]).must_equal 'image/jpeg' + _(h[:make]).must_equal 'Canon' + end + end + describe 'single-get' do it 'responds with known correct responses' do Dir['test/*.jpg'].each do |filename|