defmodule Exexif do @moduledoc """ Read TIFF and EXIF information from a JPEG-format image. iex> {:ok, info} = Exexif.exif_from_jpeg_buffer(buffer) iex> info.x_resolution 72 iex> info.model "DSC-RX100M2" ...> Exexif.Data.Gps.inspect info "41°23´16˝N,2°11´50˝E" """ alias Exexif.{Decode, Tag} alias Exexif.Data.{Gps, Thumbnail} @type t :: %{ :brightness_value => float(), :color_space => binary(), :component_configuration => binary(), :compressed_bits_per_pixel => non_neg_integer(), :contrast => binary(), :custom_rendered => binary(), :datetime_digitized => binary(), :datetime_original => binary(), :digital_zoom_ratio => non_neg_integer(), :exif_image_height => non_neg_integer(), :exif_image_width => non_neg_integer(), :exif_version => binary(), :exposure_mode => binary(), :exposure_bias_value => non_neg_integer(), :exposure_program => binary(), :exposure_time => binary(), :f_number => non_neg_integer(), :file_source => binary(), :flash => binary(), :flash_pix_version => binary(), :focal_length_in_35mm_film => non_neg_integer(), :focal_length => float(), :iso_speed_ratings => non_neg_integer(), :lens_info => [float()], :light_source => non_neg_integer(), :max_aperture_value => float(), :metering_mode => binary(), :recommended_exposure => non_neg_integer(), :saturation => binary(), :scene_capture_type => binary(), :scene_type => binary(), :sensitivity_type => binary(), :sharpness => binary(), :white_balance => binary() } # <<_::32>> @type value :: binary() @type context :: {value(), non_neg_integer(), (any() -> non_neg_integer())} @max_exif_len 2 * (65_536 + 2) @image_start_marker 0xFFD8 # @image_end_marker 0xffd9 # NOT USED @app1_marker 0xFFE1 @spec exif_from_jpeg_file(binary()) :: {:error, :no_exif_data_in_jpeg | :not_a_jpeg_file | :file.posix()} | {:ok, %{exif: t()}} @doc "Extracts EXIF from jpeg file" def exif_from_jpeg_file(name) when is_binary(name) do with {:ok, buffer} <- File.open(name, [:read], &IO.binread(&1, @max_exif_len)), do: exif_from_jpeg_buffer(buffer) end @doc "Extracts EXIF from jpeg file, raises on any error" @spec exif_from_jpeg_file!(binary()) :: %{exif: t()} | no_return() def exif_from_jpeg_file!(name) when is_binary(name) do case exif_from_jpeg_file(name) do {:ok, result} -> result {:error, error} -> raise(Exexif.ReadError, type: error, file: name) end end @spec exif_from_jpeg_buffer(binary()) :: {:error, :no_exif_data_in_jpeg | :not_a_jpeg_file} | {:ok, %{exif: t()}} @doc "Extracts EXIF from binary buffer" def exif_from_jpeg_buffer(<<@image_start_marker::16, rest::binary>>), do: read_exif(rest) def exif_from_jpeg_buffer(_), do: {:error, :not_a_jpeg_file} @spec exif_from_jpeg_buffer!(binary()) :: %{exif: t()} | no_return() @doc "Extracts EXIF from binary buffer, raises on any error" def exif_from_jpeg_buffer!(buffer) do case exif_from_jpeg_buffer(buffer) do {:ok, result} -> result {:error, error} -> raise Exexif.ReadError, type: error, file: nil end end @spec read_exif(binary()) :: {:error, :no_exif_data_in_jpeg} | {:ok, %{exif: t()}} def read_exif(<< @app1_marker::16, _len::16, "Exif"::binary, 0::16, exif::binary >>) do << byte_order::16, forty_two::binary-size(2), offset::binary-size(4), _rest::binary >> = exif endian = case byte_order do 0x4949 -> :little 0x4D4D -> :big end read_unsigned = &:binary.decode_unsigned(&1, endian) # sanity check 42 = read_unsigned.(forty_two) offset = read_unsigned.(offset) {:ok, reshape(read_ifd({exif, offset, read_unsigned}))} end def read_exif(<<0xFF::8, _number::8, len::16, data::binary>>) do (len - 2) |> skip_segment(data) |> read_exif() end def read_exif(_), do: {:error, :no_exif_data_in_jpeg} @spec skip_segment(len :: non_neg_integer(), data :: binary()) :: binary() defp skip_segment(len, data) do <<_segment::size(len)-unit(8), rest::binary>> = data rest end @spec read_ifd(context :: context()) :: map() defp read_ifd({exif, offset, ru} = context) do case exif do <<_::binary-size(offset), tag_count::binary-size(2), tags::binary>> -> read_tags(ru.(tag_count), tags, context, :tiff, []) _ -> %{} end end @spec read_tags(non_neg_integer(), binary(), context(), any(), any()) :: map() defp read_tags(0, _tags, _context, _type, result), do: Map.new(result) defp read_tags( count, << tag::binary-size(2), format::binary-size(2), component_count::binary-size(4), value::binary-size(4), rest::binary >>, {_exif, _offset, ru} = context, type, result ) do tag = ru.(tag) format = ru.(format) component_count = ru.(component_count) value = Tag.value(format, component_count, value, context) {name, description} = Decode.tag(type, tag, value) kv = case name do :exif -> {:exif, read_exif(value, context)} :gps -> {:gps, read_gps(value, context)} _ -> {name, description} end read_tags(count - 1, rest, context, type, [kv | result]) end # Handle malformed data defp read_tags(_, _, _, _, result), do: Map.new(result) def read_exif(exif_offset, {exif, _offset, ru} = context) do <<_::binary-size(exif_offset), count::binary-size(2), tags::binary>> = exif count = ru.(count) read_tags(count, tags, context, :exif, []) end @spec read_gps(non_neg_integer(), context()) :: %Gps{} defp read_gps(gps_offset, {gps, _offset, ru} = context) do case gps do <<_::binary-size(gps_offset), count::binary-size(2), tags::binary>> -> struct(Gps, read_tags(ru.(count), tags, context, :gps, [])) _ -> %Gps{} end end @spec reshape(%{exif: t()} | map()) :: %{exif: t()} | map() defp reshape(%{exif: %{} = _} = result), do: extract_thumbnail(result) defp reshape(result), do: result @spec extract_thumbnail(%{exif: t()}) :: %{exif: t()} defp extract_thumbnail(%{exif: exif} = result) do exif_keys = Map.keys(exif) result = if Enum.all?(Thumbnail.fields(), fn e -> Enum.any?(exif_keys, &(&1 == e)) end) do Map.put( result, :thumbnail, struct( Thumbnail, Thumbnail.fields() |> Enum.map(fn e -> {e, exif[e]} end) |> Enum.into(%{}) ) ) else result end %{result | exif: Map.drop(exif, Thumbnail.fields())} end end