Storehouse.Image.JPEG (Storehouse v0.2.0)

Copy Markdown View Source

Edits the metadata inside a JPEG without re-encoding it: removes where a photo was taken, keeps only a few tags, or writes an author's name, and never touches the image's data.

A JPEG's metadata lives in segments of its own, before the image data: Exif (APP1), XMP (APP1), IPTC (APP13), comments. These functions rewrite those segments and copy everything else, the tables and the compressed image, byte for byte, so the pixels a reader decodes are the same ones. Re-encoding with an image library (libvips, ImageMagick) would lose quality and change the file.

{:ok, jpeg} = Storehouse.Image.JPEG.strip(jpeg, :gps)
{:ok, jpeg} = Storehouse.Image.JPEG.strip(jpeg, {:keep, [:orientation, :copyright]})
{:ok, jpeg} = Storehouse.Image.JPEG.put_tags(jpeg, %{artist: "Ana Sousa", copyright: "© Ana Sousa"})

edit_file/3 does the same from a file to a file, with only the metadata in memory: what Storehouse.Image.MetadataEditor.Builtin, the default editor of the Storehouse.Analyzer.EditMetadata step, uses.

Nothing in Storehouse calls these on its own: a stored file's bytes change only when the app asks, with these functions or that step.

Tags

By name, from the first directory (IFD0): :orientation, :artist, :copyright, :image_description, :make, :model, :software, :date_time, :x_resolution, :y_resolution, :resolution_unit; and from the Exif directory: :date_time_original, :offset_time_original, :exposure_time, :f_number, :iso, :focal_length, :lens_make, :lens_model, :color_space.

What is left as it is

  • Data after the end of the image: a phone's gain map or depth map, a second picture its MPF segment lists. Its offsets are kept right when segments before it change size.
  • Place names: strip(jpeg, :gps) removes coordinates, not a city an app or a person wrote in IPTC or XMP.
  • Other formats: these functions refuse them with {:error, :not_jpeg}. Storehouse.Image.PNG and Storehouse.Image.WebP edit those two the same way; HEIC and TIFF are left to another editor (Storehouse.Image.MetadataEditor).

A JPEG whose segments or Exif cannot be read is refused with {:error, :malformed} or {:error, :malformed_exif}, never cut short.

Summary

Types

Why a JPEG is refused.

A tag, by name (see Tags).

Functions

The JPEG with changes made: :strip (as strip/2), then :put (as put_tags/2).

The JPEG at from, with changes made (as edit/2), written to to.

The JPEG with these tags written, or removed when nil: :artist, :copyright and :image_description, as UTF-8 text, the way ExifTool writes it (a reader that takes Exif text as Latin-1 shows letters past ASCII wrongly). A JPEG with no Exif is given one.

The JPEG without the metadata what names

The named tags (see Tags) the JPEG's Exif has, decoded: text as a string, numbers as integers, fractions as {numerator, denominator}. %{} for a JPEG with no Exif.

Types

reason()

@type reason() :: :not_jpeg | :malformed | :malformed_exif | :exif_too_large

Why a JPEG is refused.

tag()

@type tag() :: atom()

A tag, by name (see Tags).

Functions

edit(jpeg, changes)

@spec edit(binary(), Storehouse.Image.MetadataEditor.changes()) ::
  {:ok, binary()} | {:error, reason()}

The JPEG with changes made: :strip (as strip/2), then :put (as put_tags/2).

edit_file(from, to, changes)

@spec edit_file(Path.t(), Path.t(), Storehouse.Image.MetadataEditor.changes()) ::
  :ok | :unchanged | {:error, reason() | File.posix()}

The JPEG at from, with changes made (as edit/2), written to to.

Only its metadata is read into memory: the image data, most of the file, is copied from file to file, so a photo of any size takes the memory of its metadata. :unchanged when there is nothing to change, and to is not written.

put_tags(jpeg, tags)

@spec put_tags(binary(), %{optional(tag()) => String.t() | nil}) ::
  {:ok, binary()} | {:error, reason()}

The JPEG with these tags written, or removed when nil: :artist, :copyright and :image_description, as UTF-8 text, the way ExifTool writes it (a reader that takes Exif text as Latin-1 shows letters past ASCII wrongly). A JPEG with no Exif is given one.

The tags it replaces and their old directory are zeroed, so an old name does not stay in the file.

Examples

iex> {:ok, jpeg} = Storehouse.Image.JPEG.put_tags(File.read!("test/fixtures/harbour.jpg"), %{artist: "Ana"})
iex> Storehouse.Image.JPEG.tags(jpeg)
{:ok, %{artist: "Ana"}}

strip(jpeg, what)

@spec strip(binary(), :gps | {:keep, [tag()]}) :: {:ok, binary()} | {:error, reason()}

The JPEG without the metadata what names:

  • :gps - where it was taken: the Exif GPS directory, and the GPS properties of its XMP. The bytes they held are zeroed, not only unlinked. Everything else stays where it is.
  • {:keep, tags} - every tag but those named (see Tags), and all of XMP, IPTC, comments and the Exif thumbnail. What draws the image stays: the JFIF header, the ICC colour profile, Adobe's colour transform and the MPF index. XMP holds a phone's HDR gain map description too, which goes with it.

A JPEG with nothing to remove is given back as it was, byte for byte.

tags(jpeg)

@spec tags(binary()) :: {:ok, %{required(tag()) => term()}} | {:error, reason()}

The named tags (see Tags) the JPEG's Exif has, decoded: text as a string, numbers as integers, fractions as {numerator, denominator}. %{} for a JPEG with no Exif.