Read image metadata

Read image metadata

On macOS, several scriptable frameworks allow access to image metadata. The easiest approach is to use the Image Events framework that does not require ObjC at all. However, the metadata accessible by it is very limited. It doesn’t provide keywords nor geolocation data, for example.

Reading EXIF metadata#

To get all EXIF data stored in an image, you must resort to ObjC, which provides the method NSImageEXIFData. This will still not give you all metadata, since some of it is not part of the EXIF standard – to retrieve everything, you’d have to get the dictionaries IPTC and GPS, possibly also TIFF from the image. How to do that will be hinted at later.

The script below

  • defines a mapping between metadata names in Image Events and EXIF to internal names in mapping. Most of these names are prefixed with md.
  • in addition, it provides a list of functions that convert EXIF to JavaScript data types in conversionFunctions. The functions themselves all begin with convert and return strings.
  • metadataFromImage() expects the path to an image and retrieves the metadata from it using readMetadata(). It then loops over all metadata found in the image and converts their value to a JavaScript datatype if a conversion function is defined.
  • readMetadata() first retrieves the EXIF dictionary from the image. It then loops over all its entries and tries to read their content as JavaScript values. If that fails, it stores the value as is and leaves it to the calling function to convert it to a JavaScript value.
  • Finally, readMetadata() retrieves all metadata from Image Events.
ObjC.import('AppKit');

const mapping = {
  'DateTimeOriginal':  'creationDate', // from EXIF
  'model':  'mdCamera',
  'PixelXDimension':  'mdWidth',
  'PixelYDimension':  'mdHeight',
  'ApertureValue':  'mdAperture',
  'ExposureTime':  'mdExposure',
  'ISOSpeedRatings':  'mdISO',
  'SensingMethod':  'mdSensing',
  'FNumber':  'mdFNumber',
  'pixelHeight' : 'mdHeight',
  'pixelWidth' : 'mdWidth',
  'creation': 'creationDate', // from Image Events
  'profile': 'mdProfileName',
  'ExposureProgram': 'mdExposureProgram',
  'LensSpecification': 'mdLensSpecification',
  'ExposureBiasValue': 'mdExposureBias',
  'MeteringMode': 'mdMeteringMode',
};

const conversionFunctions = {
  'DateTimeOriginal': convertDate,
  'creation' : convertDate,
  'ISOSpeedRatings': convertISO,
  'profile': convertProfile,
  'ExposureProgram': convertExposureP,
  'LensSpecification': convertLensSpec,
  'MeteringMode' : convertMeteringMode,
};


function metadataFromImage(path) {
  /* set "testRun" to false if you want to do more 
     with the data than logging it */
    const testRun = true; 
    const metadata = readMetadata(path);
    Object.keys(metadata).forEach(k => {
      const target = mapping[k];
      const fct = conversionFunctions[k];
      const value = fct ? fct(metadata[k]) : metadata[k];
      if (target) {
        if (testRun) {
          console.log(`${k}:\t '${value}'\t=> ${target}`);
        } else {
          /* Do something useful with the data */
        }
      }
    })
    if (testRun) console.log(`\n***\n`)
  }

/* Get metadata from EXIF and Image Events 
   Expects the path to an image file on input,
   returns an object with the metadata
   */
function readMetadata(p) {
  const metadata = {};
  const img = $.NSImage.alloc.initWithContentsOfFile($(p));
  const rep = img.representations.objectAtIndex(0);
  const exifDict = rep.valueForProperty($.NSImageEXIFData).js;
  var exifData = {};
  Object.keys(exifDict).forEach(k => {
    let value;
    try { // Try storing JavaScript representation of value
      value = exifDict[k].js;
    } catch (e) { // No JS representation readily available: store original value for later conversion
      value = exifDict[k];
    }
    exifData[k] = value;
  })

  const IE = Application('Image Events');
  IE.includeStandardAdditions = true;
  const ieImg = IE.open(p);
  ieTags = ieImg.metadataTags();
  var ieMetadata = {};
  for (let t of ieTags) {
    const name = t.name();
    try { // Try storing the dereferenced value
      ieMetadata[name] = t.value();
    } catch (e) { // Dereferencing value failed, store original for later conversion
      ieMetadata[name] = t.value;
    }
  }
  Object.assign(metadata, exifData, ieMetadata);
  return metadata;
}

/* Convert EXIF/Image Events date to JavaScript Date object */
function convertDate(string) {
  return new Date(string.replace(/(\d{4}):(\d\d):(\d\d) /,"$1-$2-$3T"));
}

/* Return the ISO speed value */
function convertISO(isoValue) {
  return isoValue[0].js;
}

/* Get the 'name' of the Image Event 'profile' property */
function convertProfile(p) {
  return p.name();
}

/* Build a lens specification of the form 'min focus length-max focus length f/min aperture-max aperture */
function convertLensSpec(spec) {
  return `${spec[0].js.toFixed(0)}-${spec[1].js.toFixed(0)}mm f/${spec[2].js}-${spec[3].js}`;
}

/* Get textual representation of exposure program */
function convertExposureP(program) {
  const mapping = {
    '0':  'Not defined',
    '1':  'Manual',
    '2':  'Normal program',
    '3':  'Aperture priority',
    '4':  'Shutter priority',
    '5':  'Creative program (biased toward depth of field)',
    '6':  'Action program (biased toward fast shutter speed)',
    '7':  'Portrait mode (for closeup photos with the background out of focus)',
    '8':  'Landscape mode (for landscape photos with the background in focus)',
  }
  return mapping[program];
}

/* Get textual representation of metering mode */
function convertMeteringMode(mode) {
  const mapping = {
    '0': 'Unknown',
    '1': 'Average',
    '2': 'CenterWeightedAverage',
    '3': 'Spot',
    '4': 'MultiSpot',
    '5': 'Pattern',
    '6': 'Partial',
    '255': 'other',
  }
  return mapping[mode];
}

Reading all image metadata#

As mentioned before, not all metadata are available in the EXIF dictionary or via Image Events. For example, comments and keywords might be found in the IPTC metadata. To access them, you’d have to go through the Image I/O framework like so:

  const error = Ref();
  fileURL = $.CFURLCreateWithFileSystemPath(null,$(path),$.kCFURLPOSIXPathStyle, false);
  const cgImageSource = $.CGImageSourceCreateWithURL(fileURL, {});
  const imageProperties = $.CGImageSourceCopyPropertiesAtIndex(cgImageSource, 0, {});
  const xmlCFData = $.CFPropertyListCreateData(null, imageProperties, $.kCFPropertyListXMLFormat_v1_0, 0, null);
  const xml = $.NSString.stringWithUTF8String($.CFDataGetBytePtr(xmlCFData));
  const xmlNSData = xml.dataUsingEncoding($.NSUTF8StringEncoding);
  const propertyDict = $.NSPropertyListSerialization.propertyListWithDataOptionsFormatError(xmlNSData, 0, null, error);

The code creates an CGImage from the path and retrieves all metadata from it with CGImageSourceCopyPropertiesAtIndex as a CFDictionary. This is then converted to a NSDictionary via property lists because that can be easily used in JavaScript. I shamelessly stole the code for the last step from other people.

After appending .js to propertyDict, you can treat it as a normal JavaScript object. For example, you can use Object.keys(propertyDict.js) to iterate over all property names of this object. While some properties like “PixelWidth” and “Orientation” contain simple values, others have another NSDictionary as their value. Those dictionaries are named {{Exif}}, {{IPTC}} or {{GPS}}. The content of the EXIF dictionary is identical to what NSImageEXIFData returns, while the other dictionaries contain their own key-value pairs, and for some values you might need to write special functions to convert them to JavaScript.

You can download the complete script here. It was first published in a version suitable for use with DEVONthink in their forum.