# -- encoding: utf-8 --
#
# MiniExiftool
#
# This library is wrapper for the Exiftool command-line
# application (http://www.sno.phy.queensu.ca/~phil/exiftool/)
# written by Phil Harvey.
# Read and write access is done in a clean OO manner.
#
# Author: Jan Friedrich
# Copyright (c) 2007-2013 by Jan Friedrich
# Licensed under the GNU LESSER GENERAL PUBLIC LICENSE,
# Version 2.1, February 1999
#
require 'fileutils'
require 'json'
require 'pstore'
require 'rational'
require 'rbconfig'
require 'set'
require 'tempfile'
require 'time'
require 'nesty' # ANB
# Simple OO access to the Exiftool command-line application.
class MiniExiftool
VERSION = '2.3.0'
# Name of the Exiftool command-line application
@@cmd = 'exiftool'
# Hash of the standard options used when call MiniExiftool.new
@@opts = { :numerical => false, :composite => true, :ignore_minor_errors => false,
:replace_invalid_chars => false, :timestamps => Time }
# Encoding of the filesystem (filenames in command line)
@@fs_enc = Encoding.find('filesystem')
def self.opts_accessor *attrs
attrs.each do |a|
define_method a do
@opts[a]
end
define_method "#{a}=" do |val|
@opts[a] = val
end
end
end
attr_reader :filename, :errors
opts_accessor :numerical, :composite, :ignore_minor_errors,
:replace_invalid_chars, :timestamps
@@encoding_types = %w(exif iptc xmp png id3 pdf photoshop quicktime aiff mie vorbis)
def self.encoding_opt enc_type
(enc_type.to_s + '_encoding').to_sym
end
@@encoding_types.each do |enc_type|
opts_accessor encoding_opt(enc_type)
end
# +opts+ support at the moment
# * :numerical
for numerical values, default is +false+
# * :composite
for including composite tags while loading,
# default is +true+
# * :ignore_minor_errors
ignore minor errors (See -m-option
# of the exiftool command-line application, default is +false+)
# * :coord_format
set format for GPS coordinates (See
# -c-option of the exiftool command-line application, default is +nil+
# that means exiftool standard)
# * :replace_invalid_chars
replace string for invalid
# UTF-8 characters or +false+ if no replacing should be done,
# default is +false+
# * :timestamps
generating DateTime objects instead of
# Time objects if set to DateTime
, default is +Time+
#
# ATTENTION: Time objects are created using Time.local
# therefore they use your local timezone, DateTime objects instead
# are created without timezone!
# * :exif_encoding
, :iptc_encoding
,
# :xmp_encoding
, :png_encoding
,
# :id3_encoding
, :pdf_encoding
,
# :photoshop_encoding
, :quicktime_encoding
,
# :aiff_encoding
, :mie_encoding
,
# :vorbis_encoding
to set this specific encoding (see
# -charset option of the exiftool command-line application, default is
# +nil+: no encoding specified)
def initialize filename=nil, opts={}
@opts = @@opts.merge opts
if @opts[:convert_encoding]
warn 'Option :convert_encoding is not longer supported!'
warn 'Please use the String#encod* methods.'
end
@values = TagHash.new
@changed_values = TagHash.new
@errors = TagHash.new
load filename unless filename.nil?
end
def initialize_from_hash hash # :nodoc:
set_values hash
set_opts_by_heuristic
self
end
def initialize_from_json json # :nodoc:
@output = json
@errors.clear
parse_output
self
end
# Load the tags of filename.
def load filename
MiniExiftool.setup
unless filename && File.exist?(filename)
raise MiniExiftool::Error.new("File '#{filename}' does not exist.")
end
if File.directory?(filename)
raise MiniExiftool::Error.new("'#{filename}' is a directory.")
end
@filename = filename
@values.clear
@changed_values.clear
params = '-j '
params << (@opts[:numerical] ? '-n ' : '')
params << (@opts[:composite] ? '' : '-e ')
params << (@opts[:coord_format] ? "-c \"#{@opts[:coord_format]}\"" : '')
@@encoding_types.each do |enc_type|
if enc_val = @opts[MiniExiftool.encoding_opt(enc_type)]
params << "-charset #{enc_type}=#{enc_val} "
end
end
if run(cmd_gen(params, @filename))
parse_output
else
raise MiniExiftool::Error.new(@error_text)
end
self
end
# Reload the tags of an already read file.
def reload
load @filename
end
# Returns the value of a tag.
def [] tag
@changed_values[tag] || @values[tag]
end
# Set the value of a tag.
def []= tag, val
@changed_values[tag] = val
end
# Returns true if any tag value is changed or if the value of a
# given tag is changed.
def changed? tag=false
if tag
@changed_values.include? tag
else
!@changed_values.empty?
end
end
# Revert all changes or the change of a given tag.
def revert tag=nil
if tag
val = @changed_values.delete(tag)
res = val != nil
else
res = @changed_values.size > 0
@changed_values.clear
end
res
end
# Returns an array of the tags (original tag names) of the read file.
def tags
@values.keys.map { |key| MiniExiftool.original_tag(key) }
end
# Returns an array of all changed tags.
def changed_tags
@changed_values.keys.map { |key| MiniExiftool.original_tag(key) }
end
# Save the changes to the file.
def save
MiniExiftool.setup
return false if @changed_values.empty?
@errors.clear
temp_file = Tempfile.new('mini_exiftool')
temp_file.close
temp_filename = temp_file.path
FileUtils.cp filename.encode(@@fs_enc), temp_filename
all_ok = true
@changed_values.each do |tag, val|
original_tag = MiniExiftool.original_tag(tag)
arr_val = val.kind_of?(Array) ? val : [val]
arr_val.map! {|e| convert_before_save(e)}
params = '-q -P -overwrite_original '
params << (arr_val.detect {|x| x.kind_of?(Numeric)} ? '-n ' : '')
params << (@opts[:ignore_minor_errors] ? '-m ' : '')
arr_val.each do |v|
params << %Q(-#{original_tag}=#{escape(v)} )
end
result = run(cmd_gen(params, temp_filename))
unless result
all_ok = false
@errors[tag] = @error_text.gsub(/Nothing to do.\n\z/, '').chomp
end
end
if all_ok
FileUtils.cp temp_filename, filename.encode(@@fs_enc)
reload
end
temp_file.delete
all_ok
end
def save!
unless save
err = []
@errors.each do |key, value|
err << "(#{key}) #{value}"
end
raise MiniExiftool::Error.new("MiniExiftool couldn't save. The following errors occurred: #{err.empty? ? "None" : err.join(", ")}")
end
end
# Returns a hash of the original loaded values of the MiniExiftool
# instance.
def to_hash
result = {}
@values.each do |k,v|
result[MiniExiftool.original_tag(k)] = v
end
result
end
# Returns a YAML representation of the original loaded values of the
# MiniExiftool instance.
def to_yaml
to_hash.to_yaml
end
# Create a MiniExiftool instance from a hash. Default value
# conversions will be applied if neccesary.
def self.from_hash hash, opts={}
instance = MiniExiftool.new nil, opts
instance.initialize_from_hash hash
instance
end
# Create a MiniExiftool instance from JSON data. Default value
# conversions will be applied if neccesary.
def self.from_json json, opts={}
instance = MiniExiftool.new nil, opts
instance.initialize_from_json json
instance
end
# Create a MiniExiftool instance from YAML data created with
# MiniExiftool#to_yaml
def self.from_yaml yaml, opts={}
MiniExiftool.from_hash YAML.load(yaml), opts
end
# Returns the command name of the called Exiftool application.
def self.command
@@cmd
end
# Setting the command name of the called Exiftool application.
def self.command= cmd
@@cmd = cmd
end
# Returns the options hash.
def self.opts
@@opts
end
# Returns a set of all known tags of Exiftool.
def self.all_tags
unless defined? @@all_tags
@@all_tags = pstore_get :all_tags
end
@@all_tags
end
# Returns a set of all possible writable tags of Exiftool.
def self.writable_tags
unless defined? @@writable_tags
@@writable_tags = pstore_get :writable_tags
end
@@writable_tags
end
# Returns the original Exiftool name of the given tag
def self.original_tag tag
unless defined? @@all_tags_map
@@all_tags_map = pstore_get :all_tags_map
end
@@all_tags_map[tag]
end
# Returns the version of the Exiftool command-line application.
def self.exiftool_version
output = `#{MiniExiftool.command} -ver 2>&1`
unless $?.exitstatus == 0
raise MiniExiftool::Error.new("Command '#{MiniExiftool.command}' not found")
end
output.chomp!
end
def self.unify tag
tag.to_s.gsub(/[-_]/,'').downcase
end
# Exception class
class MiniExiftool::Error < Nesty::NestedStandardError; end # ANB
############################################################################
private
############################################################################
@@setup_done = false
def self.setup
return if @@setup_done
@@error_file = Tempfile.new 'errors'
@@error_file.close
@@setup_done = true
end
def cmd_gen arg_str='', filename
[@@cmd, arg_str.encode('UTF-8'), escape(filename.encode(@@fs_enc))].map {|s| s.force_encoding('UTF-8')}.join(' ')
end
def run cmd
if $DEBUG
$stderr.puts cmd
end
@output = `#{cmd} 2>#{@@error_file.path}`
@status = $?
unless @status.exitstatus == 0
@error_text = File.readlines(@@error_file.path).join
@error_text.force_encoding('UTF-8')
return false
else
@error_text = ''
return true
end
end
def convert_before_save val
case val
when Time
val = val.strftime('%Y:%m:%d %H:%M:%S')
end
val
end
def method_missing symbol, *args
tag_name = symbol.id2name
if tag_name.sub!(/=$/, '')
self[tag_name] = args.first
else
self[tag_name]
end
end
def parse_output
adapt_encoding
set_values JSON.parse(@output).first
end
def adapt_encoding
@output.force_encoding('UTF-8')
if @opts[:replace_invalid_chars] && !@output.valid_encoding?
@output.encode!('UTF-16le', invalid: :replace, replace: @opts[:replace_invalid_chars]).encode!('UTF-8')
end
end
def convert_after_load tag, value
return value unless value.kind_of?(String)
return value unless value.valid_encoding?
case value
when /^\d{4}:\d\d:\d\d \d\d:\d\d:\d\d/
s = value.sub(/^(\d+):(\d+):/, '\1-\2-')
begin
if @opts[:timestamps] == Time
value = Time.parse(s)
elsif @opts[:timestamps] == DateTime
value = DateTime.parse(s)
else
raise MiniExiftool::Error.new("Value #{@opts[:timestamps]} not allowed for option timestamps.")
end
rescue ArgumentError
value = false
end
when /^\+\d+\.\d+$/
value = value.to_f
when /^0+[1-9]+$/
# nothing => String
when /^-?\d+$/
value = value.to_i
when %r(^(\d+)/(\d+)$)
value = Rational($1.to_i, $2.to_i)
when /^[\d ]+$/
# nothing => String
end
value
end
def set_values hash
hash.each_pair do |tag,val|
@values[tag] = convert_after_load(tag, val)
end
# Remove filename specific tags use attr_reader
# MiniExiftool#filename instead
# Cause: value of tag filename and attribute
# filename have different content, the latter
# holds the filename with full path (like the
# sourcefile tag) and the former the basename
# of the filename also there is no official
# "original tag name" for sourcefile
%w(directory filename sourcefile).each do |t|
@values.delete(t)
end
end
def set_opts_by_heuristic
@opts[:composite] = tags.include?('ImageSize')
@opts[:numerical] = self.file_size.kind_of?(Integer)
@opts[:timestamps] = self.FileModifyDate.kind_of?(DateTime) ? DateTime : Time
end
def self.pstore_get attribute
load_or_create_pstore unless defined? @@pstore
result = nil
@@pstore.transaction(true) do |ps|
result = ps[attribute]
end
result
end
@@running_on_windows = /mswin|mingw|cygwin/ === RbConfig::CONFIG['host_os']
def self.load_or_create_pstore
# This will hopefully work on *NIX and Windows systems
home = ENV['HOME'] || ENV['HOMEDRIVE'] + ENV['HOMEPATH'] || ENV['USERPROFILE']
subdir = @@running_on_windows ? '_mini_exiftool' : '.mini_exiftool'
FileUtils.mkdir_p(File.join(home, subdir))
pstore_filename = File.join(home, subdir, 'exiftool_tags_' << exiftool_version.gsub('.', '_') << '.pstore')
@@pstore = PStore.new pstore_filename
if !File.exist?(pstore_filename) || File.size(pstore_filename) == 0
@@pstore.transaction do |ps|
ps[:all_tags] = all_tags = determine_tags('list')
ps[:writable_tags] = determine_tags('listw')
map = {}
all_tags.each { |k| map[unify(k)] = k }
ps[:all_tags_map] = map
end
end
end
def self.determine_tags arg
output = `#{@@cmd} -#{arg}`
lines = output.split(/\n/)
tags = Set.new
lines.each do |line|
next unless line =~ /^\s/
tags |= line.chomp.split
end
tags
end
if @@running_on_windows
def escape val
'"' << val.to_s.gsub(/([\\"])/, "\\\\\\1") << '"'
end
else
def escape val
'"' << val.to_s.gsub(/([\\"$])/, "\\\\\\1") << '"'
end
end
# Hash with indifferent access:
# DateTimeOriginal == datetimeoriginal == date_time_original
class TagHash < Hash # :nodoc:
def[] k
super(unify(k))
end
def []= k, v
super(unify(k), v)
end
def delete k
super(unify(k))
end
def unify tag
MiniExiftool.unify tag
end
end
end
# ANB - dump of real @values:
# exiftoolversion=9.41
# filesize=128 kB
# filemodifydate=2014-04-02T23:00:50+04:00
# fileaccessdate=2014-04-02T23:00:51+04:00
# fileinodechangedate=2014-04-02T23:00:50+04:00
# filepermissions=rw-r--r--
# filetype=JPEG
# mimetype=image/jpeg
# jfifversion=1.01
# exifbyteorder=Little-endian (Intel, II)
# imagedescription=
# make=SONY
# model=SLT-A65V
# xresolution=350
# yresolution=350
# resolutionunit=inches
# software=SLT-A65V v1.05
# modifydate=2014-04-02T20:50:32+00:00
# artist=Andrey Bizyaev (photographer); Andrey Bizyaev (camera owner)
# ycbcrpositioning=Co-sited
# copyright=2013 (c) Andrey Bizyaev. All Rights Reserved.
# exposuretime=1/100
# fnumber=5.6
# exposureprogram=Program AE
# iso=160
# sensitivitytype=Recommended Exposure Index
# recommendedexposureindex=160
# exifversion=230
# datetimeoriginal=2013-01-03T15:39:08+00:00
# createdate=2013-01-03T15:39:08+00:00
# componentsconfiguration=Y, Cb, Cr, -
# compressedbitsperpixel=2
# brightnessvalue=6.3375
# exposurecompensation=0
# maxaperturevalue=5.6
# meteringmode=Multi-segment
# lightsource=Unknown
# flash=Off, Did not fire
# focallength=55.0 mm
# quality=Fine
# flashexposurecomp=0
# teleconverter=None
# whitebalancefinetune=0
# rating=0
# brightness=0
# longexposurenoisereduction=On (unused)
# highisonoisereduction=Normal
# hdr=Off; Uncorrected image
# multiframenoisereduction=Off
# pictureeffect=Off
# softskineffect=Off
# vignettingcorrection=Auto
# lateralchromaticaberration=Auto
# distortioncorrection=Off
# wbshiftabgm=0 0
# faceinfooffset=94
# sonydatetime=2013-01-03T15:39:08+00:00
# sonyimagewidth=6000
# facesdetected=0
# faceinfolength=37
# metaversion=DC7303320222000
# maxaperture=5.3
# minaperture=33
# flashstatus=Built-in Flash present
# imagecount=8330
# lensmount=A-Mount
# lensformat=APS-C
# sequenceimagenumber=1
# sequencefilenumber=1
# releasemode2=Normal
# shotnumbersincepowerup=2
# sequencelength=1 shot
# cameraorientation=Horizontal (normal)
# quality2=JPEG
# sonyimageheight=4000
# modelreleaseyear=2011
# batterylevel=18%
# afpointsselected=(all)
# fileformat=ARW 2.3
# sonymodelid=SLT-A65 / SLT-A65V
# creativestyle=Standard
# colortemperature=Auto
# colorcompensationfilter=0
# scenemode=Auto
# zonematching=ISO Setting Used
# dynamicrangeoptimizer=Auto
# imagestabilization=On
# lenstype=Sony DT 16-105mm F3.5-5.6 (SAL16105)
# colormode=Standard
# lensspec=DT 16-105mm F3.5-5.6
# fullimagesize=6000x4000
# previewimagesize=1616x1080
# flashlevel=Normal
# releasemode=Normal
# sequencenumber=Single
# antiblur=On (Shooting)
# intelligentauto=On
# whitebalance=Auto
# usercomment=
# flashpixversion=100
# colorspace=sRGB
# exifimagewidth=800
# exifimageheight=534
# interopindex=R98 - DCF basic file (sRGB)
# interopversion=100
# relatedimagewidth=6000
# relatedimageheight=4000
# filesource=Digital Camera
# scenetype=Directly photographed
# customrendered=Normal
# exposuremode=Auto
# focallengthin35mmformat=82 mm
# scenecapturetype=Standard
# contrast=Normal
# saturation=Normal
# sharpness=Normal
# imageuniqueid=20140402-205030-0001
# lensinfo=16-105mm f/3.5-5.6
# lensmodel=DT 16-105mm F3.5-5.6
# gpsversionid=2.3.0.0
# gpslatituderef=North
# gpslongituderef=East
# gpsaltituderef=Above Sea Level
# gpstimestamp=11:39:09.588
# gpsstatus=Measurement Active
# gpsmeasuremode=3-Dimensional Measurement
# gpsdop=2.0026
# gpsspeedref=km/h
# gpsspeed=1.097
# gpstrackref=True North
# gpstrack=357.15
# gpsmapdatum=WGS-84
# gpsdatestamp=2013:01:03
# gpsdifferential=No Correction
# printimversion=300
# compression=JPEG (old-style)
# orientation=Horizontal (normal)
# thumbnailoffset=21840
# thumbnaillength=5859
# xmptoolkit=Image::ExifTool 9.41
# location=Дворцовая пл.
# locationshowncity=Санкт-Петербург
# locationshowncountrycode=RU
# locationshowncountryname=Russia
# locationshownprovincestate=Санкт-Петербург
# locationshownsublocation=Дворцовая пл.
# locationshownworldregion=Europe
# creator=["Andrey Bizyaev (photographer)", "Andrey Bizyaev (camera owner)"]
# rights=2013 (c) Andrey Bizyaev. All Rights Reserved.
# subject=["before-what-travel", "before-who-Andrew", "before-where-Baltic", "before-when-day", "before-why-vacation", "before-how-fine", "before-method-digicam"]
# collectionname=S-Peterburg Travel
# collectionuri=anblab.net
# country=Russia
# state=Санкт-Петербург
# iptcdigest=1569c0bffab4b64cb1107134254cf97d
# currentiptcdigest=7be9172b29568717f9bc0976c93ba53d
# codedcharacterset=UTF8
# enveloperecordversion=4
# keywords=["before-what-travel", "before-who-Andrew", "before-where-Baltic", "before-when-day", "before-why-vacation", "before-how-fine", "before-method-digicam"]
# byline=["Andrey Bizyaev (photographer)", "Andrey Bizyaev (camera owner)"]
# city=Санкт-Петербург
# sublocation=Дворцовая пл.
# provincestate=Санкт-Петербург
# countryprimarylocationname=Russia
# copyrightnotice=2013 (c) Andrey Bizyaev. All Rights Reserved.
# applicationrecordversion=4
# imagewidth=800
# imageheight=534
# encodingprocess=Baseline DCT, Huffman coding
# bitspersample=8
# colorcomponents=3
# ycbcrsubsampling=YCbCr4:4:4 (1 1)
# aperture=5.6
# gpsaltitude=0.5 m Above Sea Level
# gpsdatetime=2013-01-03T11:39:09+00:00
# gpslatitude=60 deg 0' 0.00" N
# gpslongitude=25 deg 0' 0.00" E
# gpsposition=60 deg 0' 0.00" N, 25 deg 0' 0.00" E
# imagesize=800x534
# lensid=Sony DT 16-105mm F3.5-5.6 (SAL16105)
# scalefactor35efl=1.5
# shutterspeed=1/100
# thumbnailimage=(Binary data 5859 bytes)
# circleofconfusion=0.020 mm
# fov=24.8 deg
# focallength35efl=55.0 mm (35 mm equivalent: 82.0 mm)
# hyperfocaldistance=26.80 m
# lightvalue=10.9