├── .formatter.exs ├── .github └── workflows │ ├── dialyzer.yml.dont-use │ └── test.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── exexif.ex └── exexif │ ├── data │ ├── gps.ex │ └── thumbnail.ex │ ├── decode.ex │ ├── read_error.ex │ └── tag.ex ├── mix.exs ├── mix.lock └── test ├── exexif_test.exs ├── gps_test.exs ├── images ├── apple-aperture-1.5.app1 ├── cactus.jpg ├── gopro_hd2.app1 ├── malformed.jpg ├── negative-exposure-bias-value.app1 ├── non_standard_custom_rendered.jpg ├── pp_editors.jpg ├── pp_editors_no_exif.jpg └── sunrise.jpg ├── test_helper.exs └── thumbnail_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/dialyzer.yml.dont-use: -------------------------------------------------------------------------------- 1 | name: Dialyzer 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 11 | strategy: 12 | matrix: 13 | otp: [21.3, 22.2, 23.0] 14 | elixir: [1.9.4, 1.10.2] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-elixir@v1 18 | with: 19 | otp-version: ${{matrix.otp}} 20 | elixir-version: ${{matrix.elixir}} 21 | - name: Install → Compile dependencies → Quality Check 22 | run: | 23 | MIX_ENV=ci mix do deps.get, deps.compile, compile, quality.ci 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 9 | strategy: 10 | matrix: 11 | otp: [21.3, 22.2, 23.0] 12 | elixir: [1.9.4, 1.10.2] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-elixir@v1 16 | with: 17 | otp-version: ${{matrix.otp}} 18 | elixir-version: ${{matrix.elixir}} 19 | - name: Install → Compile dependencies → Test 20 | run: | 21 | MIX_ENV=test mix do deps.get, deps.compile, compile, test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /doc 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | =============== 3 | 4 | Copyright (c) 2014 Dave Thomas, The Pragmatic Bookshelf 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exexif 2 | 3 | _Read TIFF and EXIF information from a JPEG-format image._ 4 | 5 | ### Retrieve data from a file: 6 | 7 | ```elixir 8 | iex> {:ok, info} = Exexif.exif_from_jpeg_file(path) 9 | ``` 10 | 11 | Retrieve data from a binary containing the JPEG (you don't need the whole 12 | thing—the exif is near the beginning of a JPEG, so 100k or so should 13 | do fine). 14 | 15 | ```elixir 16 | iex> {:ok, info} = Exexif.exif_from_jpeg_buffer(buffer) 17 | ``` 18 | 19 | ### Access the high level TIFF data: 20 | 21 | ```elixir 22 | iex> info.x_resolution 23 | 72 24 | iex> info.model 25 | "DSC-RX100M2" 26 | ``` 27 | 28 | ### The exif data is in there, too. 29 | 30 | ```elixir 31 | iex> info.exif.color_space 32 | "sRGB" 33 | ``` 34 | 35 | ```elixir 36 | iex> info.exif |> Dict.keys 37 | [:brightness_value, :color_space, :component_configuration, 38 | :compressed_bits_per_pixel, :contrast, :custom_rendered, :datetime_original, 39 | :datetime_digitized, :digital_zoom_ratio, :exif_image_height, 40 | :exif_image_width, :exif_version, :exposure_bias_value, :exposure_mode, 41 | :exposure_program, :exposure_time, :f_number, :file_source, :flash, 42 | :flash_pix_persion, :focal_length, :focal_length_in_35mm_film, 43 | :iso_speed_ratings, :lens_info, :light_source, :max_aperture_value, 44 | :metering_mode, :recommended_exposure, :saturation, :scene_capture_type, 45 | :scene_type, :sensitivity_type, :sharpness, :white_balance] 46 | ``` 47 | 48 | ### GPS data is in there, too (if presented in EXIF, of course.) 49 | 50 | ```elixir 51 | iex> {:ok, info} = Exexif.exif_from_jpeg_file("test/images/sunrise.jpg") 52 | {:ok, 53 | %{exif: %{color_space: "Uncalibrated", exif_version: "2.10", ...}, 54 | gps: %Exexif.Data.Gps{gps_altitude: 47, gps_altitude_ref: 0, ...}, 55 | make: "ulefone", model: "Power", modify_date: "\"2016:12:28 14:04:48\"", 56 | orientation: "Horizontal (normal)", resolution_units: "Pixels/in", 57 | x_resolution: 72, y_resolution: 72}} 58 | 59 | iex> info.gps.gps_latitude 60 | [41, 23, 16.019] 61 | 62 | iex> "#{info.gps}" 63 | "41°23´16˝N,2°11´50˝E" 64 | ``` 65 | 66 | Todo 67 | ---- 68 | 69 | The exif tag list is missing some of the newer entries. Contributions welcome. 70 | 71 | 72 | License and Copyright 73 | --------------------- 74 | 75 | See LICENSE.md 76 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /lib/exexif.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexif do 2 | @moduledoc """ 3 | Read TIFF and EXIF information from a JPEG-format image. 4 | 5 | iex> {:ok, info} = Exexif.exif_from_jpeg_buffer(buffer) 6 | iex> info.x_resolution 7 | 72 8 | iex> info.model 9 | "DSC-RX100M2" 10 | ...> Exexif.Data.Gps.inspect info 11 | "41°23´16˝N,2°11´50˝E" 12 | """ 13 | 14 | alias Exexif.{Decode, Tag} 15 | alias Exexif.Data.{Gps, Thumbnail} 16 | 17 | @type t :: %{ 18 | :brightness_value => float(), 19 | :color_space => binary(), 20 | :component_configuration => binary(), 21 | :compressed_bits_per_pixel => non_neg_integer(), 22 | :contrast => binary(), 23 | :custom_rendered => binary(), 24 | :datetime_digitized => binary(), 25 | :datetime_original => binary(), 26 | :digital_zoom_ratio => non_neg_integer(), 27 | :exif_image_height => non_neg_integer(), 28 | :exif_image_width => non_neg_integer(), 29 | :exif_version => binary(), 30 | :exposure_mode => binary(), 31 | :exposure_bias_value => non_neg_integer(), 32 | :exposure_program => binary(), 33 | :exposure_time => binary(), 34 | :f_number => non_neg_integer(), 35 | :file_source => binary(), 36 | :flash => binary(), 37 | :flash_pix_version => binary(), 38 | :focal_length_in_35mm_film => non_neg_integer(), 39 | :focal_length => float(), 40 | :iso_speed_ratings => non_neg_integer(), 41 | :lens_info => [float()], 42 | :light_source => non_neg_integer(), 43 | :max_aperture_value => float(), 44 | :metering_mode => binary(), 45 | :recommended_exposure => non_neg_integer(), 46 | :saturation => binary(), 47 | :scene_capture_type => binary(), 48 | :scene_type => binary(), 49 | :sensitivity_type => binary(), 50 | :sharpness => binary(), 51 | :white_balance => binary() 52 | } 53 | 54 | # <<_::32>> 55 | @type value :: binary() 56 | @type context :: {value(), non_neg_integer(), (any() -> non_neg_integer())} 57 | 58 | @max_exif_len 2 * (65_536 + 2) 59 | 60 | @image_start_marker 0xFFD8 61 | # @image_end_marker 0xffd9 # NOT USED 62 | 63 | @app1_marker 0xFFE1 64 | 65 | @spec exif_from_jpeg_file(binary()) :: 66 | {:error, :no_exif_data_in_jpeg | :not_a_jpeg_file | :file.posix()} 67 | | {:ok, %{exif: t()}} 68 | @doc "Extracts EXIF from jpeg file" 69 | def exif_from_jpeg_file(name) when is_binary(name) do 70 | with {:ok, buffer} <- File.open(name, [:read], &IO.binread(&1, @max_exif_len)), 71 | do: exif_from_jpeg_buffer(buffer) 72 | end 73 | 74 | @doc "Extracts EXIF from jpeg file, raises on any error" 75 | @spec exif_from_jpeg_file!(binary()) :: %{exif: t()} | no_return() 76 | def exif_from_jpeg_file!(name) when is_binary(name) do 77 | case exif_from_jpeg_file(name) do 78 | {:ok, result} -> result 79 | {:error, error} -> raise(Exexif.ReadError, type: error, file: name) 80 | end 81 | end 82 | 83 | @spec exif_from_jpeg_buffer(binary()) :: 84 | {:error, :no_exif_data_in_jpeg | :not_a_jpeg_file} | {:ok, %{exif: t()}} 85 | @doc "Extracts EXIF from binary buffer" 86 | def exif_from_jpeg_buffer(<<@image_start_marker::16, rest::binary>>), 87 | do: read_exif(rest) 88 | 89 | def exif_from_jpeg_buffer(_), do: {:error, :not_a_jpeg_file} 90 | 91 | @spec exif_from_jpeg_buffer!(binary()) :: %{exif: t()} | no_return() 92 | @doc "Extracts EXIF from binary buffer, raises on any error" 93 | def exif_from_jpeg_buffer!(buffer) do 94 | case exif_from_jpeg_buffer(buffer) do 95 | {:ok, result} -> result 96 | {:error, error} -> raise Exexif.ReadError, type: error, file: nil 97 | end 98 | end 99 | 100 | @spec read_exif(binary()) :: {:error, :no_exif_data_in_jpeg} | {:ok, %{exif: t()}} 101 | def read_exif(<< 102 | @app1_marker::16, 103 | _len::16, 104 | "Exif"::binary, 105 | 0::16, 106 | exif::binary 107 | >>) do 108 | << 109 | byte_order::16, 110 | forty_two::binary-size(2), 111 | offset::binary-size(4), 112 | _rest::binary 113 | >> = exif 114 | 115 | endian = 116 | case byte_order do 117 | 0x4949 -> :little 118 | 0x4D4D -> :big 119 | end 120 | 121 | read_unsigned = &:binary.decode_unsigned(&1, endian) 122 | 123 | # sanity check 124 | 42 = read_unsigned.(forty_two) 125 | offset = read_unsigned.(offset) 126 | 127 | {:ok, reshape(read_ifd({exif, offset, read_unsigned}))} 128 | end 129 | 130 | def read_exif(<<0xFF::8, _number::8, len::16, data::binary>>) do 131 | (len - 2) 132 | |> skip_segment(data) 133 | |> read_exif() 134 | end 135 | 136 | def read_exif(_), do: {:error, :no_exif_data_in_jpeg} 137 | 138 | @spec skip_segment(len :: non_neg_integer(), data :: binary()) :: binary() 139 | defp skip_segment(len, data) do 140 | <<_segment::size(len)-unit(8), rest::binary>> = data 141 | rest 142 | end 143 | 144 | @spec read_ifd(context :: context()) :: map() 145 | defp read_ifd({exif, offset, ru} = context) do 146 | case exif do 147 | <<_::binary-size(offset), tag_count::binary-size(2), tags::binary>> -> 148 | read_tags(ru.(tag_count), tags, context, :tiff, []) 149 | 150 | _ -> 151 | %{} 152 | end 153 | end 154 | 155 | @spec read_tags(non_neg_integer(), binary(), context(), any(), any()) :: map() 156 | defp read_tags(0, _tags, _context, _type, result), do: Map.new(result) 157 | 158 | defp read_tags( 159 | count, 160 | << 161 | tag::binary-size(2), 162 | format::binary-size(2), 163 | component_count::binary-size(4), 164 | value::binary-size(4), 165 | rest::binary 166 | >>, 167 | {_exif, _offset, ru} = context, 168 | type, 169 | result 170 | ) do 171 | tag = ru.(tag) 172 | format = ru.(format) 173 | component_count = ru.(component_count) 174 | value = Tag.value(format, component_count, value, context) 175 | {name, description} = Decode.tag(type, tag, value) 176 | 177 | kv = 178 | case name do 179 | :exif -> {:exif, read_exif(value, context)} 180 | :gps -> {:gps, read_gps(value, context)} 181 | _ -> {name, description} 182 | end 183 | 184 | read_tags(count - 1, rest, context, type, [kv | result]) 185 | end 186 | 187 | # Handle malformed data 188 | defp read_tags(_, _, _, _, result), do: Map.new(result) 189 | 190 | def read_exif(exif_offset, {exif, _offset, ru} = context) do 191 | <<_::binary-size(exif_offset), count::binary-size(2), tags::binary>> = exif 192 | count = ru.(count) 193 | read_tags(count, tags, context, :exif, []) 194 | end 195 | 196 | @spec read_gps(non_neg_integer(), context()) :: %Gps{} 197 | defp read_gps(gps_offset, {gps, _offset, ru} = context) do 198 | case gps do 199 | <<_::binary-size(gps_offset), count::binary-size(2), tags::binary>> -> 200 | struct(Gps, read_tags(ru.(count), tags, context, :gps, [])) 201 | 202 | _ -> 203 | %Gps{} 204 | end 205 | end 206 | 207 | @spec reshape(%{exif: t()}) :: %{exif: t()} 208 | defp reshape(result), do: extract_thumbnail(result) 209 | 210 | @spec extract_thumbnail(%{exif: t()}) :: %{exif: t()} 211 | defp extract_thumbnail(result) do 212 | exif_keys = Map.keys(result.exif) 213 | 214 | result = 215 | if Enum.all?(Thumbnail.fields(), fn e -> Enum.any?(exif_keys, &(&1 == e)) end) do 216 | Map.put( 217 | result, 218 | :thumbnail, 219 | struct( 220 | Thumbnail, 221 | Thumbnail.fields() 222 | |> Enum.map(fn e -> {e, result.exif[e]} end) 223 | |> Enum.into(%{}) 224 | ) 225 | ) 226 | else 227 | result 228 | end 229 | 230 | %{result | exif: Map.drop(result.exif, Thumbnail.fields())} 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /lib/exexif/data/gps.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexif.Data.Gps do 2 | @moduledoc """ 3 | Internal representation of GPS tag in the EXIF. 4 | """ 5 | 6 | @type t :: %Exexif.Data.Gps{ 7 | gps_version_id: any(), 8 | gps_latitude_ref: any(), 9 | gps_latitude: any(), 10 | gps_longitude_ref: any(), 11 | gps_longitude: any(), 12 | gps_altitude_ref: any(), 13 | gps_altitude: any(), 14 | gps_time_stamp: any(), 15 | gps_satellites: any(), 16 | gps_status: any(), 17 | gps_measure_mode: any(), 18 | gps_dop: any(), 19 | gps_speed_ref: any(), 20 | gps_speed: any(), 21 | gps_track_ref: any(), 22 | gps_track: any(), 23 | gps_img_direction_ref: any(), 24 | gps_img_direction: any(), 25 | gps_map_datum: any(), 26 | gps_dest_latitude_ref: any(), 27 | gps_dest_latitude: any(), 28 | gps_dest_longitude_ref: any(), 29 | gps_dest_longitude: any(), 30 | gps_dest_bearing_ref: any(), 31 | gps_dest_bearing: any(), 32 | gps_dest_distance_ref: any(), 33 | gps_dest_distance: any(), 34 | gps_processing_method: any(), 35 | gps_area_information: any(), 36 | gps_date_stamp: any(), 37 | gps_differential: any(), 38 | gps_h_positioning_error: any() 39 | } 40 | 41 | @fields [ 42 | :gps_version_id, 43 | :gps_latitude_ref, 44 | :gps_latitude, 45 | :gps_longitude_ref, 46 | :gps_longitude, 47 | :gps_altitude_ref, 48 | :gps_altitude, 49 | :gps_time_stamp, 50 | :gps_satellites, 51 | :gps_status, 52 | :gps_measure_mode, 53 | :gps_dop, 54 | :gps_speed_ref, 55 | :gps_speed, 56 | :gps_track_ref, 57 | :gps_track, 58 | :gps_img_direction_ref, 59 | :gps_img_direction, 60 | :gps_map_datum, 61 | :gps_dest_latitude_ref, 62 | :gps_dest_latitude, 63 | :gps_dest_longitude_ref, 64 | :gps_dest_longitude, 65 | :gps_dest_bearing_ref, 66 | :gps_dest_bearing, 67 | :gps_dest_distance_ref, 68 | :gps_dest_distance, 69 | :gps_processing_method, 70 | :gps_area_information, 71 | :gps_date_stamp, 72 | :gps_differential, 73 | :gps_h_positioning_error 74 | ] 75 | 76 | @spec fields :: [atom()] 77 | @doc false 78 | def fields, do: @fields 79 | 80 | defstruct @fields 81 | 82 | @spec inspect(data :: t()) :: String.t() 83 | @doc """ 84 | Returns the human-readable representation of GPS data, e. g. "41°23´16˝N,2°11´50˝E". 85 | """ 86 | def inspect(%Exexif.Data.Gps{gps_latitude: nil} = _data), do: "" 87 | def inspect(%Exexif.Data.Gps{gps_longitude: nil} = _data), do: "" 88 | 89 | def inspect(%Exexif.Data.Gps{} = data) do 90 | # gps_latitude: [41, 23, 16.019], gps_latitude_ref: "N", 91 | # gps_longitude: [2, 11, 49.584], gps_longitude_ref: "E" 92 | # 41 deg 23' 16.02" N, 2 deg 11' 49.58" E 93 | [lat_d, lat_m, lat_s] = data.gps_latitude 94 | [lon_d, lon_m, lon_s] = data.gps_longitude 95 | 96 | [ 97 | ~s|#{lat_d}°#{lat_m}´#{round(lat_s)}˝#{data.gps_latitude_ref || "N"}|, 98 | ~s|#{lon_d}°#{lon_m}´#{round(lon_s)}˝#{data.gps_longitude_ref || "N"}| 99 | ] 100 | |> Enum.join(",") 101 | end 102 | 103 | defimpl String.Chars, for: Exexif.Data.Gps do 104 | @moduledoc false 105 | alias Exexif.Data.Gps 106 | 107 | @spec to_string(Gps.t()) :: String.t() 108 | def to_string(data), do: Gps.inspect(data) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/exexif/data/thumbnail.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexif.Data.Thumbnail do 2 | @moduledoc """ 3 | Internal representation of Thumbnail tag in the EXIF. 4 | """ 5 | @type t :: %Exexif.Data.Thumbnail{ 6 | thumbnail_offset: non_neg_integer(), 7 | thumbnail_size: non_neg_integer() 8 | } 9 | 10 | @fields [ 11 | :thumbnail_offset, 12 | :thumbnail_size 13 | ] 14 | 15 | @spec fields :: [:thumbnail_offset | :thumbnail_size] 16 | @doc false 17 | def fields, do: @fields 18 | 19 | defstruct @fields 20 | 21 | @spec to_image(binary(), t()) :: :ok | {:error, :bad_thumbnail_data | :file.posix()} 22 | @doc "Converts the thumbnail to image and writes it to the file" 23 | def to_image(_, %Exexif.Data.Thumbnail{thumbnail_offset: offset, thumbnail_size: size}) 24 | when is_nil(offset) or is_nil(size), 25 | do: {:error, :bad_thumbnail_data} 26 | 27 | def to_image(file, %Exexif.Data.Thumbnail{thumbnail_offset: offset, thumbnail_size: size}) 28 | when is_binary(file) do 29 | [name, dot, ext] = String.split(file, ~r/(?=.{3,4}\z)/) 30 | 31 | with {:ok, src} <- 32 | File.open(file, [:read], fn f -> 33 | IO.binread(f, offset) 34 | IO.binread(f, size) 35 | end), 36 | {:ok, _dst} <- File.open("#{name}-thumb#{dot}#{ext}", [:write], &IO.binwrite(&1, src)), 37 | do: :ok 38 | end 39 | 40 | defimpl String.Chars, for: Exexif.Data.Thumbnail do 41 | @spec to_string(data :: Exexif.Data.Thumbnail.t()) :: <<_::64, _::_*8>> 42 | def to_string(data), do: "Image Thumbnail of size #{data.thumbnail_size}" 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/exexif/decode.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexif.Decode do 2 | @moduledoc """ 3 | Decode tags and (in some cases) their parameters. 4 | """ 5 | 6 | alias Exexif.Data.Gps 7 | 8 | @spec tag(atom(), non_neg_integer(), value) :: {atom | <<_::64, _::_*8>>, value} 9 | when value: binary() | float() | non_neg_integer() 10 | @doc "Returns the decoded and humanized tag out of raw exif representation." 11 | def tag(:tiff, 0x0100, value), do: {:image_width, value} 12 | def tag(:tiff, 0x0101, value), do: {:image_height, value} 13 | def tag(:tiff, 0x010D, value), do: {:document_name, value} 14 | def tag(:tiff, 0x010E, value), do: {:image_description, value} 15 | def tag(:tiff, 0x010F, value), do: {:make, value} 16 | def tag(:tiff, 0x0110, value), do: {:model, value} 17 | def tag(:tiff, 0x0112, value), do: {:orientation, orientation(value)} 18 | def tag(:tiff, 0x011A, value), do: {:x_resolution, value} 19 | def tag(:tiff, 0x011B, value), do: {:y_resolution, value} 20 | def tag(:tiff, 0x0128, value), do: {:resolution_units, resolution(value)} 21 | def tag(:tiff, 0x0131, value), do: {:software, value} 22 | def tag(:tiff, 0x0132, value), do: {:modify_date, inspect(value)} 23 | 24 | def tag(:tiff, 0x8769, value), do: {:exif, value} 25 | def tag(:tiff, 0x8825, value), do: {:gps, value} 26 | 27 | def tag(:exif, 0x0201, value), do: {:thumbnail_offset, value} 28 | def tag(:exif, 0x0202, value), do: {:thumbnail_size, value} 29 | 30 | def tag(_, 0x829A, value), do: {:exposure_time, value} 31 | def tag(_, 0x829D, value), do: {:f_number, value} 32 | def tag(_, 0x8822, value), do: {:exposure_program, exposure_program(value)} 33 | def tag(_, 0x8824, value), do: {:spectral_sensitivity, value} 34 | def tag(_, 0x8827, value), do: {:iso_speed_ratings, value} 35 | def tag(_, 0x8828, value), do: {:oecf, value} 36 | def tag(_, 0x8830, value), do: {:sensitivity_type, sensitivity_type(value)} 37 | def tag(_, 0x8831, value), do: {:standard_output_sensitivity, value} 38 | def tag(_, 0x8832, value), do: {:recommended_exposure, value} 39 | def tag(_, 0x9000, value), do: {:exif_version, version(value)} 40 | def tag(_, 0x9003, value), do: {:datetime_original, value} 41 | def tag(_, 0x9004, value), do: {:datetime_digitized, value} 42 | def tag(_, 0x9101, value), do: {:component_configuration, component_configuration(value)} 43 | def tag(_, 0x9102, value), do: {:compressed_bits_per_pixel, value} 44 | def tag(_, 0x9201, value), do: {:shutter_speed_value, value} 45 | def tag(_, 0x9202, value), do: {:aperture_value, value} 46 | def tag(_, 0x9203, value), do: {:brightness_value, value} 47 | def tag(_, 0x9204, value), do: {:exposure_bias_value, value} 48 | def tag(_, 0x9205, value), do: {:max_aperture_value, value} 49 | def tag(_, 0x9206, value), do: {:subject_distance, value} 50 | def tag(_, 0x9207, value), do: {:metering_mode, metering_mode(value)} 51 | def tag(_, 0x9208, value), do: {:light_source, value} 52 | def tag(_, 0x9209, value), do: {:flash, flash(value)} 53 | def tag(_, 0x920A, value), do: {:focal_length, value} 54 | def tag(_, 0x9214, value), do: {:subject_area, value} 55 | def tag(_, 0x927C, value), do: {:maker_note, value} 56 | def tag(_, 0x9286, value), do: {:user_comment, value} 57 | def tag(_, 0x9290, value), do: {:subsec_time, value} 58 | def tag(_, 0x9291, value), do: {:subsec_time_orginal, value} 59 | def tag(_, 0x9292, value), do: {:subsec_time_digitized, value} 60 | def tag(_, 0xA000, value), do: {:flash_pix_version, version(value)} 61 | def tag(_, 0xA001, value), do: {:color_space, color_space(value)} 62 | def tag(_, 0xA002, value), do: {:exif_image_width, value} 63 | def tag(_, 0xA003, value), do: {:exif_image_height, value} 64 | def tag(_, 0xA004, value), do: {:related_sound_file, value} 65 | def tag(_, 0xA20B, value), do: {:flash_energy, value} 66 | def tag(_, 0xA20C, value), do: {:spatial_frequency_response, value} 67 | def tag(_, 0xA20E, value), do: {:focal_plane_x_resolution, value} 68 | def tag(_, 0xA20F, value), do: {:focal_plane_y_resolution, value} 69 | 70 | def tag(_, 0xA210, value), 71 | do: {:focal_plane_resolution_unit, focal_plane_resolution_unit(value)} 72 | 73 | def tag(_, 0xA214, value), do: {:subject_location, value} 74 | def tag(_, 0xA215, value), do: {:exposure_index, value} 75 | def tag(_, 0xA217, value), do: {:sensing_method, sensing_method(value)} 76 | def tag(_, 0xA300, value), do: {:file_source, file_source(value)} 77 | def tag(_, 0xA301, value), do: {:scene_type, scene_type(value)} 78 | def tag(_, 0xA302, value), do: {:cfa_pattern, value} 79 | def tag(_, 0xA401, value), do: {:custom_rendered, custom_rendered(value)} 80 | def tag(_, 0xA402, value), do: {:exposure_mode, exposure_mode(value)} 81 | def tag(_, 0xA403, value), do: {:white_balance, white_balance(value)} 82 | def tag(_, 0xA404, value), do: {:digital_zoom_ratio, value} 83 | def tag(_, 0xA405, value), do: {:focal_length_in_35mm_film, value} 84 | def tag(_, 0xA406, value), do: {:scene_capture_type, scene_capture_type(value)} 85 | def tag(_, 0xA407, value), do: {:gain_control, gain_control(value)} 86 | def tag(_, 0xA408, value), do: {:contrast, contrast(value)} 87 | def tag(_, 0xA409, value), do: {:saturation, saturation(value)} 88 | def tag(_, 0xA40A, value), do: {:sharpness, sharpness(value)} 89 | def tag(_, 0xA40B, value), do: {:device_setting_description, value} 90 | def tag(_, 0xA40C, value), do: {:subject_distance_range, subject_distance_range(value)} 91 | def tag(_, 0xA420, value), do: {:image_unique_id, value} 92 | def tag(_, 0xA432, value), do: {:lens_info, value} 93 | def tag(_, 0xA433, value), do: {:lens_make, value} 94 | def tag(_, 0xA434, value), do: {:lens_model, value} 95 | def tag(_, 0xA435, value), do: {:lens_serial_number, value} 96 | 97 | # http://www.sno.phy.queensu.ca/~phil/exiftool/TagNames/GPS.html 98 | Gps.fields() 99 | |> Enum.with_index() 100 | |> Enum.each(fn {e, i} -> 101 | def tag(:gps, unquote(i), value), do: {unquote(e), value} 102 | end) 103 | 104 | def tag(type, tag, value) do 105 | {~s[#{type} tag(0x#{:io_lib.format("~.16B", [tag])})], inspect(value)} 106 | end 107 | 108 | # Value decodes 109 | 110 | @spec orientation(non_neg_integer()) :: binary() 111 | defp orientation(1), do: "Horizontal (normal)" 112 | defp orientation(2), do: "Mirror horizontal" 113 | defp orientation(3), do: "Rotate 180" 114 | defp orientation(4), do: "Mirror vertical" 115 | defp orientation(5), do: "Mirror horizontal and rotate 270 CW" 116 | defp orientation(6), do: "Rotate 90 CW" 117 | defp orientation(7), do: "Mirror horizontal and rotate 90 CW" 118 | defp orientation(8), do: "Rotate 270 CW" 119 | defp orientation(other), do: "Unknown (#{other})" 120 | 121 | @spec resolution(non_neg_integer()) :: binary() 122 | defp resolution(1), do: "None" 123 | defp resolution(2), do: "Pixels/in" 124 | defp resolution(3), do: "Pixels/cm" 125 | defp resolution(other), do: "Unknown (#{other})" 126 | 127 | @spec exposure_program(non_neg_integer()) :: binary() 128 | defp exposure_program(1), do: "Manual" 129 | defp exposure_program(2), do: "Program AE" 130 | defp exposure_program(3), do: "Aperture-priority AE" 131 | defp exposure_program(4), do: "Shutter speed priority AE" 132 | defp exposure_program(5), do: "Creative (Slow speed)" 133 | defp exposure_program(6), do: "Action (High speed)" 134 | defp exposure_program(7), do: "Portrait" 135 | defp exposure_program(8), do: "Landscape" 136 | defp exposure_program(9), do: "Bulb" 137 | defp exposure_program(other), do: "Unknown (#{other})" 138 | 139 | @spec sensitivity_type(non_neg_integer()) :: binary() 140 | defp sensitivity_type(1), do: "Standard Output Sensitivity" 141 | defp sensitivity_type(2), do: "Recommended Exposure Index" 142 | defp sensitivity_type(3), do: "ISO Speed" 143 | defp sensitivity_type(4), do: " Standard Output Sensitivity and Recommended Exposure Index" 144 | defp sensitivity_type(5), do: "Standard Output Sensitivity and ISO Speed" 145 | defp sensitivity_type(6), do: "Recommended Exposure Index and ISO Speed" 146 | 147 | defp sensitivity_type(7), 148 | do: "Standard Output Sensitivity, Recommended Exposure Index and ISO Speed" 149 | 150 | defp sensitivity_type(other), do: "Unknown (#{other})" 151 | 152 | @comp_conf {"-", "Y", "Cb", "Cr", "R", "G", "B"} 153 | 154 | @spec component_configuration([non_neg_integer()]) :: binary() 155 | defp component_configuration(list) do 156 | list 157 | |> Enum.map(&elem(@comp_conf, &1)) 158 | |> Enum.join(",") 159 | end 160 | 161 | @spec metering_mode(non_neg_integer()) :: binary() 162 | defp metering_mode(1), do: "Average" 163 | defp metering_mode(2), do: "Center-weighted average" 164 | defp metering_mode(3), do: "Spot" 165 | defp metering_mode(4), do: "Multi-spot" 166 | defp metering_mode(5), do: "Multi-segment" 167 | defp metering_mode(6), do: "Partial" 168 | defp metering_mode(other), do: "Unknown (#{other})" 169 | 170 | @spec color_space(non_neg_integer()) :: binary() 171 | defp color_space(0x1), do: "sRGB" 172 | defp color_space(0x2), do: "Adobe RGB" 173 | defp color_space(0xFFFD), do: "Wide Gamut RGB" 174 | defp color_space(0xFFFE), do: "ICC Profile" 175 | defp color_space(0xFFFF), do: "Uncalibrated" 176 | defp color_space(other), do: "Unknown (#{other})" 177 | 178 | @spec focal_plane_resolution_unit(non_neg_integer()) :: binary() 179 | defp focal_plane_resolution_unit(1), do: "None" 180 | defp focal_plane_resolution_unit(2), do: "inches" 181 | defp focal_plane_resolution_unit(3), do: "cm" 182 | defp focal_plane_resolution_unit(4), do: "mm" 183 | defp focal_plane_resolution_unit(5), do: "um" 184 | defp focal_plane_resolution_unit(other), do: "Unknown (#{other})" 185 | 186 | @spec sensing_method(non_neg_integer()) :: binary() 187 | defp sensing_method(1), do: "Not defined" 188 | defp sensing_method(2), do: "One-chip color area" 189 | defp sensing_method(3), do: "Two-chip color area" 190 | defp sensing_method(4), do: "Three-chip color area" 191 | defp sensing_method(5), do: "Color sequential area" 192 | defp sensing_method(7), do: "Trilinear" 193 | defp sensing_method(8), do: "Color sequential linear" 194 | defp sensing_method(other), do: "Unknown (#{other})" 195 | 196 | @spec file_source(non_neg_integer()) :: binary() 197 | defp file_source(1), do: "Film Scanner" 198 | defp file_source(2), do: "Reflection Print Scanner" 199 | defp file_source(3), do: "Digital Camera" 200 | defp file_source(0x03000000), do: "Sigma Digital Camera" 201 | defp file_source(other), do: "Unknown (#{other})" 202 | 203 | @spec custom_rendered(non_neg_integer()) :: binary() 204 | defp custom_rendered(0), do: "Normal" 205 | defp custom_rendered(1), do: "Custom" 206 | defp custom_rendered(other), do: "Unknown (#{other})" 207 | 208 | @spec scene_type(non_neg_integer()) :: binary() 209 | defp scene_type(1), do: "Directly photographed" 210 | defp scene_type(other), do: "Unknown (#{other})" 211 | 212 | @spec exposure_mode(non_neg_integer()) :: binary() 213 | defp exposure_mode(0), do: "Auto" 214 | defp exposure_mode(1), do: "Manual" 215 | defp exposure_mode(2), do: "Auto bracket" 216 | defp exposure_mode(other), do: "Unknown (#{other})" 217 | 218 | @spec white_balance(non_neg_integer()) :: binary() 219 | defp white_balance(0), do: "Auto" 220 | defp white_balance(1), do: "Manual" 221 | defp white_balance(other), do: "Unknown (#{other})" 222 | 223 | @spec scene_capture_type(non_neg_integer()) :: binary() 224 | defp scene_capture_type(0), do: "Standard" 225 | defp scene_capture_type(1), do: "Landscape" 226 | defp scene_capture_type(2), do: "Portrait" 227 | defp scene_capture_type(3), do: "Night" 228 | defp scene_capture_type(other), do: "Unknown (#{other})" 229 | 230 | @spec gain_control(non_neg_integer()) :: binary() 231 | defp gain_control(0), do: "None" 232 | defp gain_control(1), do: "Low gain up" 233 | defp gain_control(2), do: "High gain up" 234 | defp gain_control(3), do: "Low gain down" 235 | defp gain_control(4), do: "High gain down" 236 | defp gain_control(other), do: "Unknown (#{other})" 237 | 238 | @spec contrast(non_neg_integer()) :: binary() 239 | defp contrast(0), do: "Normal" 240 | defp contrast(1), do: "Low" 241 | defp contrast(2), do: "High" 242 | defp contrast(other), do: "Unknown (#{other})" 243 | 244 | @spec saturation(non_neg_integer()) :: binary() 245 | defp saturation(0), do: "Normal" 246 | defp saturation(1), do: "Low" 247 | defp saturation(2), do: "High" 248 | defp saturation(other), do: "Unknown (#{other})" 249 | 250 | @spec sharpness(non_neg_integer()) :: binary() 251 | defp sharpness(0), do: "Normal" 252 | defp sharpness(1), do: "Soft" 253 | defp sharpness(2), do: "Hard" 254 | defp sharpness(other), do: "Unknown (#{other})" 255 | 256 | @spec subject_distance_range(non_neg_integer()) :: binary() 257 | defp subject_distance_range(0), do: "Unknown" 258 | defp subject_distance_range(1), do: "Macro" 259 | defp subject_distance_range(2), do: "Close" 260 | defp subject_distance_range(3), do: "Distant" 261 | defp subject_distance_range(other), do: "Unknown (#{other})" 262 | 263 | @spec flash(non_neg_integer()) :: binary() 264 | defp flash(0x0), do: "No Flash" 265 | defp flash(0x1), do: "Fired" 266 | defp flash(0x5), do: "Fired, Return not detected" 267 | defp flash(0x7), do: "Fired, Return detected" 268 | defp flash(0x8), do: "On, Did not fire" 269 | defp flash(0x9), do: "On, Fired" 270 | defp flash(0xD), do: "On, Return not detected" 271 | defp flash(0xF), do: "On, Return detected" 272 | defp flash(0x10), do: "Off, Did not fire" 273 | defp flash(0x14), do: "Off, Did not fire, Return not detected" 274 | defp flash(0x18), do: "Auto, Did not fire" 275 | defp flash(0x19), do: "Auto, Fired" 276 | defp flash(0x1D), do: "Auto, Fired, Return not detected" 277 | defp flash(0x1F), do: "Auto, Fired, Return detected" 278 | defp flash(0x20), do: "No flash function" 279 | defp flash(0x30), do: "Off, No flash function" 280 | defp flash(0x41), do: "Fired, Red-eye reduction" 281 | defp flash(0x45), do: "Fired, Red-eye reduction, Return not detected" 282 | defp flash(0x47), do: "Fired, Red-eye reduction, Return detected" 283 | defp flash(0x49), do: "On, Red-eye reduction" 284 | defp flash(0x4D), do: "On, Red-eye reduction, Return not detected" 285 | defp flash(0x4F), do: "On, Red-eye reduction, Return detected" 286 | defp flash(0x50), do: "Off, Red-eye reduction" 287 | defp flash(0x58), do: "Auto, Did not fire, Red-eye reduction" 288 | defp flash(0x59), do: "Auto, Fired, Red-eye reduction" 289 | defp flash(0x5D), do: "Auto, Fired, Red-eye reduction, Return not detected" 290 | defp flash(0x5F), do: "Auto, Fired, Red-eye reduction, Return detected" 291 | defp flash(other), do: "Unknown (#{other})" 292 | 293 | @spec version(charlist()) :: binary() 294 | defp version([?0, major, minor1, minor2]) do 295 | <> 296 | end 297 | 298 | defp version([major1, major2, minor1, minor2]) do 299 | <> 300 | end 301 | end 302 | -------------------------------------------------------------------------------- /lib/exexif/read_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexif.ReadError do 2 | @moduledoc """ 3 | The error raised on any attempt to deal with incorrect files. 4 | """ 5 | defexception [:type, :file, :message] 6 | 7 | @impl true 8 | def exception(type: type, file: file) do 9 | msg = 10 | case file do 11 | nil -> "Error reading EXIF data from buffer" 12 | _ -> "Error reading EXIF data from file [#{file}]" 13 | end 14 | 15 | %Exexif.ReadError{type: type, file: file, message: msg} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/exexif/tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexif.Tag do 2 | @moduledoc """ 3 | Parse the different tag type values (strings, unsigned shorts, etc...) 4 | """ 5 | 6 | @max_signed_32_bit_int 2_147_483_647 7 | 8 | # unsigned byte, size = 1 9 | @spec value(non_neg_integer(), non_neg_integer(), Exexif.value(), Exexif.context()) :: any() 10 | def value(1, count, value, context), 11 | do: decode_numeric(value, count, 2, context) 12 | 13 | # ascii string, size = 1 14 | def value(2, count, value, {exif, _offset, ru}) do 15 | # ignore null-byte at end 16 | length = count - 1 17 | 18 | if count > 4 do 19 | # + offset 20 | offset = ru.(value) 21 | <<_::binary-size(offset), string::binary-size(length), _::binary>> = exif 22 | string 23 | else 24 | <> = value 25 | string 26 | end 27 | end 28 | 29 | # unsigned short, size = 2 30 | def value(3, count, value, context), 31 | do: decode_numeric(value, count, 2, context) 32 | 33 | # unsigned long, size = 4 34 | def value(4, count, value, context), 35 | do: decode_numeric(value, count, 4, context) 36 | 37 | # unsigned rational, size = 8 38 | def value(5, count, value, context), 39 | do: decode_ratio(value, count, context, :unsigned) 40 | 41 | # undefined, size = 1 42 | def value(7, count, value, context), 43 | do: decode_numeric(value, count, 1, context) 44 | 45 | # signed rational, size = 8 46 | def value(10, count, value, context), 47 | do: decode_ratio(value, count, context, :signed) 48 | 49 | # Handle malformed tags 50 | def value(_, _, _, _), do: nil 51 | 52 | @spec decode_numeric( 53 | value :: Exexif.value(), 54 | non_neg_integer(), 55 | non_neg_integer(), 56 | Exexif.context() 57 | ) :: any() 58 | defp decode_numeric(value, count, size, {exif, _offset, ru}) do 59 | length = count * size 60 | 61 | values = 62 | if length > 4 do 63 | case exif do 64 | <<_::binary-size(value), data::binary-size(length), _::binary>> -> data 65 | # probably a maker_note or user_comment 66 | _ -> nil 67 | end 68 | else 69 | <> = value 70 | data 71 | end 72 | 73 | if values do 74 | if count == 1 do 75 | ru.(values) 76 | else 77 | read_unsigned_many(values, size, ru) 78 | end 79 | end 80 | end 81 | 82 | @spec decode_ratio( 83 | Exexif.value(), 84 | non_neg_integer(), 85 | Exexif.context(), 86 | :unsigned | :signed 87 | ) :: any() 88 | defp decode_ratio(value_offset, count, {exif, _offset, ru}, signed) do 89 | exif 90 | |> decode_ratios(count, ru.(value_offset), ru, signed) 91 | |> do_decode_ratio(count) 92 | end 93 | 94 | @spec do_decode_ratio(list(), non_neg_integer()) :: any() 95 | defp do_decode_ratio([result | _], 1), do: result 96 | defp do_decode_ratio(result, _), do: result 97 | 98 | @spec decode_ratios( 99 | value :: Exexif.value(), 100 | non_neg_integer(), 101 | non_neg_integer(), 102 | (any() -> non_neg_integer()), 103 | :unsigned | :signed 104 | ) :: list() 105 | defp decode_ratios(_data, 0, _offset, _ru, _signed), do: [] 106 | 107 | defp decode_ratios(data, count, offset, ru, signed) do 108 | case data do 109 | <<_::binary-size(offset), numerator::binary-size(4), denominator::binary-size(4), 110 | rest::binary>> -> 111 | d = maybe_signed_int(ru.(denominator), signed) 112 | n = maybe_signed_int(ru.(numerator), signed) 113 | 114 | result = 115 | case {d, n} do 116 | {1, n} -> n 117 | {d, 1} -> "1/#{d}" 118 | {0, _} -> :infinity 119 | {d, n} -> round(n * 1000 / d) / 1000 120 | end 121 | 122 | [result | decode_ratios(rest, count - 1, 0, ru, signed)] 123 | 124 | _ -> 125 | [] 126 | end 127 | end 128 | 129 | @spec read_unsigned_many(binary(), non_neg_integer(), ([any()] -> binary())) :: any() 130 | defp read_unsigned_many(<<>>, _size, _ru), do: [] 131 | 132 | defp read_unsigned_many(data, size, ru) do 133 | <> = data 134 | [ru.(number) | read_unsigned_many(rest, size, ru)] 135 | end 136 | 137 | @spec maybe_signed_int(non_neg_integer(), :singed | :unsigned) :: non_neg_integer() 138 | defp maybe_signed_int(x, :signed) when x > @max_signed_32_bit_int, 139 | do: x - (@max_signed_32_bit_int + 1) * 2 140 | 141 | # +ve or unsigned 142 | defp maybe_signed_int(x, _), do: x 143 | end 144 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Exexif.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :nextexif, 7 | version: "0.1.0", 8 | elixir: ">= 1.6.0", 9 | deps: deps(), 10 | aliases: aliases(), 11 | description: description(), 12 | package: package() 13 | ] 14 | end 15 | 16 | def application do 17 | [ 18 | applications: [] 19 | ] 20 | end 21 | 22 | defp description do 23 | """ 24 | Read TIFF and EXIF information from a JPEG-format image. 25 | 26 | iex> {:ok, info} = Exexif.exif_from_jpeg_buffer(buffer) 27 | iex> info.x_resolution 28 | 72 29 | iex> info.model 30 | "DSC-RX100M2" 31 | ...> Exexif.Data.Gps.inspect info 32 | "41°23´16˝N,2°11´50˝E" 33 | """ 34 | end 35 | 36 | @me "Aleksei Matiushkin " 37 | defp package do 38 | [ 39 | files: ["lib", "mix.exs", "README.md", "LICENSE.md"], 40 | maintainers: [@me], 41 | contributors: ["Dave Thomas ", @me], 42 | licenses: ["MIT. See LICENSE.md"], 43 | links: %{ 44 | "GitHub" => "https://github.com/am-kantox/exexif" 45 | } 46 | ] 47 | end 48 | 49 | defp aliases do 50 | [ 51 | quality: ["format", "credo --strict", "dialyzer"], 52 | "quality.ci": [ 53 | "format --check-formatted", 54 | "credo --strict", 55 | "dialyzer" 56 | ] 57 | ] 58 | end 59 | 60 | defp deps do 61 | [ 62 | {:ex_doc, ">= 0.0.0", only: [:dev], runtime: false}, 63 | {:credo, "~> 1.0", only: [:dev, :ci], runtime: false}, 64 | {:dialyxir, "~> 1.0", only: [:dev, :test, :ci], runtime: false} 65 | ] 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, 4 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, 5 | "earmark": {:hex, :earmark, "1.4.10", "bddce5e8ea37712a5bfb01541be8ba57d3b171d3fa4f80a0be9bcf1db417bcaf", [:mix], [{:earmark_parser, ">= 1.4.10", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "12dbfa80810478e521d3ffb941ad9fbfcbbd7debe94e1341b4c4a1b2411c1c27"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"}, 9 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, 10 | "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, 13 | } 14 | -------------------------------------------------------------------------------- /test/exexif_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExexifTest do 2 | use ExUnit.Case 3 | import Exexif 4 | 5 | @data File.read!("test/images/pp_editors.jpg") 6 | @test_exif %{ 7 | :brightness_value => 7.084, 8 | :color_space => "sRGB", 9 | :component_configuration => "Y,Cb,Cr,-", 10 | :compressed_bits_per_pixel => 2, 11 | :contrast => "Normal", 12 | :custom_rendered => "Normal", 13 | :datetime_digitized => "2014:05:14 11:57:07", 14 | :datetime_original => "2014:05:14 11:57:07", 15 | :digital_zoom_ratio => 1, 16 | :exif_image_height => 335, 17 | :exif_image_width => 800, 18 | :exif_version => "2.30", 19 | :exposure_mode => "Auto", 20 | :exposure_bias_value => 0, 21 | :exposure_program => "Program AE", 22 | :exposure_time => "1/200", 23 | :f_number => 4, 24 | :file_source => "Digital Camera", 25 | :flash => "Off, Did not fire", 26 | :flash_pix_version => "1.00", 27 | :focal_length_in_35mm_film => 28, 28 | :focal_length => 10.4, 29 | :iso_speed_ratings => 160, 30 | :lens_info => [10.4, 37.1, 1.8, 4.9], 31 | :light_source => 0, 32 | :max_aperture_value => 1.695, 33 | :metering_mode => "Multi-segment", 34 | :recommended_exposure => 160, 35 | :saturation => "Normal", 36 | :scene_capture_type => "Standard", 37 | :scene_type => "Directly photographed", 38 | :sensitivity_type => "Recommended Exposure Index", 39 | :sharpness => "Normal", 40 | :white_balance => "Auto" 41 | } 42 | 43 | test "looks for jpeg marker" do 44 | assert {:error, :not_a_jpeg_file} = exif_from_jpeg_buffer("wombat") 45 | end 46 | 47 | test "correctly reports no exif" do 48 | no_exif = "test/images/pp_editors_no_exif.jpg" 49 | assert {:error, :no_exif_data_in_jpeg} = exif_from_jpeg_file(no_exif) 50 | end 51 | 52 | test "correctly reports no exif (banged version)" do 53 | no_exif = "test/images/pp_editors_no_exif.jpg" 54 | 55 | assert_raise Exexif.ReadError, ~r/Error reading EXIF data from file/, fn -> 56 | exif_from_jpeg_file!(no_exif) 57 | end 58 | end 59 | 60 | test "correctly reports no exif for (banged version, buffer)" do 61 | no_exif = File.read!("test/images/pp_editors_no_exif.jpg") 62 | 63 | assert_raise Exexif.ReadError, ~r/Error reading EXIF data from buffer/, fn -> 64 | exif_from_jpeg_buffer!(no_exif) 65 | end 66 | end 67 | 68 | test "handles exif" do 69 | assert {:ok, _metadata} = exif_from_jpeg_buffer(@data) 70 | end 71 | 72 | test "tiff fields are reasonable" do 73 | {:ok, metadata} = exif_from_jpeg_buffer(@data) 74 | 75 | assert %{ 76 | :image_description => " ", 77 | :make => "SONY", 78 | :model => "DSC-RX100M2", 79 | :modify_date => "\"2014:05:14 11:57:07\"", 80 | :orientation => "Horizontal (normal)", 81 | :resolution_units => "Pixels/in", 82 | :software => "DSC-RX100M2 v1.00", 83 | :x_resolution => 72, 84 | :y_resolution => 72 85 | } = metadata 86 | end 87 | 88 | test "exif fields are reasonable" do 89 | with {:ok, metadata} <- exif_from_jpeg_buffer(@data) do 90 | assert @test_exif = metadata.exif 91 | end 92 | end 93 | 94 | test "banged version works as expected" do 95 | assert @test_exif = exif_from_jpeg_buffer!(@data).exif 96 | end 97 | 98 | test "malformed images" do 99 | assert {:ok, data} = exif_from_jpeg_file("test/images/malformed.jpg") 100 | assert %{make: "SAMSUNG"} = data 101 | end 102 | 103 | test "bad values" do 104 | # Apple Aperture inserts invalid values 105 | assert {:ok, data} = exif_from_app1_file("test/images/apple-aperture-1.5.app1") 106 | assert %{make: "NIKON CORPORATION"} = data 107 | end 108 | 109 | test "infinity" do 110 | # GoPro uses ratio numerator 0 to relay infinity 111 | assert {:ok, data} = exif_from_app1_file("test/images/gopro_hd2.app1") 112 | assert %{exif: %{subject_distance: :infinity}} = data 113 | end 114 | 115 | test "negative exposure bias" do 116 | assert {:ok, data} = exif_from_app1_file("test/images/negative-exposure-bias-value.app1") 117 | assert %{exif: %{exposure_bias_value: -0.333}} = data 118 | end 119 | 120 | test "handles non-standard CustomRendered tag" do 121 | assert {:ok, data} = exif_from_jpeg_file("test/images/non_standard_custom_rendered.jpg") 122 | assert %{exif: %{custom_rendered: "Unknown (4)"}} = data 123 | end 124 | 125 | defp exif_from_app1_file(path), 126 | do: path |> File.read!() |> Exexif.read_exif() 127 | end 128 | -------------------------------------------------------------------------------- /test/gps_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GpsTest do 2 | use ExUnit.Case 3 | import Exexif 4 | 5 | @data File.read!("test/images/sunrise.jpg") 6 | 7 | test "tiff fields are reasonable" do 8 | {:ok, metadata} = exif_from_jpeg_buffer(@data) 9 | 10 | assert %{ 11 | :make => "ulefone", 12 | :model => "Power", 13 | :modify_date => "\"2016:12:28 14:04:48\"", 14 | :orientation => "Horizontal (normal)", 15 | :resolution_units => "Pixels/in", 16 | :x_resolution => 72, 17 | :y_resolution => 72 18 | } = metadata 19 | end 20 | 21 | test "gps fields are reasonable" do 22 | {:ok, metadata} = exif_from_jpeg_buffer(@data) 23 | 24 | assert %Exexif.Data.Gps{ 25 | gps_altitude: 47, 26 | gps_altitude_ref: 0, 27 | gps_date_stamp: "2016:12:27", 28 | gps_img_direction: 125.25, 29 | gps_img_direction_ref: "M", 30 | gps_latitude: [41, 23, 16.019], 31 | gps_latitude_ref: "N", 32 | gps_longitude: [2, 11, 49.584], 33 | gps_longitude_ref: "E", 34 | gps_processing_method: 0, 35 | gps_time_stamp: [6, 42, 48] 36 | } = metadata.gps 37 | end 38 | 39 | test "gps is printed in human readable manner" do 40 | {:ok, metadata} = exif_from_jpeg_buffer(@data) 41 | 42 | assert "#{metadata.gps}" == "41°23´16˝N,2°11´50˝E" 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/images/apple-aperture-1.5.app1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/apple-aperture-1.5.app1 -------------------------------------------------------------------------------- /test/images/cactus.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/cactus.jpg -------------------------------------------------------------------------------- /test/images/gopro_hd2.app1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/gopro_hd2.app1 -------------------------------------------------------------------------------- /test/images/malformed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/malformed.jpg -------------------------------------------------------------------------------- /test/images/negative-exposure-bias-value.app1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/negative-exposure-bias-value.app1 -------------------------------------------------------------------------------- /test/images/non_standard_custom_rendered.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/non_standard_custom_rendered.jpg -------------------------------------------------------------------------------- /test/images/pp_editors.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/pp_editors.jpg -------------------------------------------------------------------------------- /test/images/pp_editors_no_exif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/pp_editors_no_exif.jpg -------------------------------------------------------------------------------- /test/images/sunrise.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pragdave/exexif/8bb3b1b5b11c221c385a14cc5d32937d4fbd2daa/test/images/sunrise.jpg -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/thumbnail_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ThumbnailTest do 2 | use ExUnit.Case 3 | 4 | @filename "test/images/cactus.jpg" 5 | @thumbname "test/images/cactus-thumb.jpg" 6 | 7 | alias Exexif.Data.Thumbnail 8 | import Exexif 9 | 10 | test "thumbnail fields are recognized properly" do 11 | metadata = exif_from_jpeg_file!(@filename) 12 | 13 | Thumbnail.to_image(@filename, metadata.thumbnail) 14 | assert File.exists?(@thumbname) 15 | File.rm!(@thumbname) 16 | 17 | assert %Thumbnail{ 18 | thumbnail_offset: 631, 19 | thumbnail_size: 19_837 20 | } = metadata.thumbnail 21 | end 22 | 23 | # test "gps is printed in human readable manner" do 24 | # {:ok, metadata} = exif_from_jpeg_buffer(@data) 25 | 26 | # assert "#{metadata.gps}" == "41°23´16˝N,2°11´50˝E" 27 | 28 | # end 29 | end 30 | --------------------------------------------------------------------------------