Script: Getting image metadata into DT

Which image metadata are available

Digital images can contain a lot of metadata. Best known are probably the location (where the image was taken), the dimensions (width and height in pixels) and parameters like exposure time, focal length and aperture value. In addition, there can be keywords defined for an image, a copyright notice, a title, or the author. Although this sounds exciting, it is a lot less so if you try to retrieve these parameters by scripting.

Long story short: Only a few of all possible image metadata is accessible to scripting, be it in AppleScript or JavaScript. They fall into two sets: Metadata available in the ā€œImage Eventsā€ framework and (some!) EXIF data. These sets are partially overlapping, in that the Image Events API provides image dimension and creation date, which are also available in the EXIF set. Also, images might lack all EXIF data originally available because they were dropped sometime during copying them from one app to another.

Additionally, information like keywords, author, and title are not part of EXIF nor of Image Events metadata. They belong to the IPTC data set, which is not directly accessible to scripting. At least, I couldnā€™t find a way to do so.

However, a lot of the EXIF, IPTC and other image metadata are accessible via Spotlight. Which means that one can programmatically read them using the shell command mdls. However, most of these data is removed on import, and some of it stored in DT properties. For example, the kMDItemKeywords attribute is converted into keywords in DTā€™s metadata.

Which metadata to store in DT

DT does not copy all the available image metadata to its own properties. But is still possible to get them, as long as they are stored in the image file itself.

The only image metadata accessible to scripting that can be directly copied to a DT property is the creation date of the image. That should happen automatically when you import the image. All other data like aperture, exposure value or camera model must be saved as custom metadata. And although one can specify a datatype for a custom metadata field in the global preferences, this is not possible when setting the field value in a script.

Instead, DT tries to be clever and set the datatype for a custom metadata field on its own, depending on the value it sees. That works ok most of the time, but not necessarily always.

Script: Copying image metadata to DT

The script below copies some metadata from images stored in DT to custom metadata fields. These are the ones in the imageā€™s EXIF data and the Image Events framework. As said before, not all EXIF data are accessible via Appleā€™s scripting interface, though. The script operates on the currently selected records only, and it ignores all records that are not images with the file extension ā€œjpegā€ or ā€œjpgā€.

Mapping image to DT metadata fields

At the top of the script, the object mapping defines the relationship between image and DT metadata fields like so:

const mapping = {
  'DateTimeOriginal':  'creationDate', // from EXIF
  'model':  'mdCamera',
...

Here, DateTimeOriginal is an EXIF data, and creationDate is the property of a DT record to map it to. Similarly, model is the camera model in the EXIF data, and mdCamera maps it to the Camera custom metadata field. You can add new mappings to this object like so

'MyOwnEXIFField': 'recordProperty',
'MyOtherEXIFField': 'mdCustomMetadataField',

The text before the colon refers to an EXIF or Image Events metadata field. The text after the colon

  • must be a DT property if it does not begin with ā€˜mdā€™
  • is considered to be a custom metadata field if it begins with ā€˜mdā€™. The name of the custom metadata field is the rest of the text following ā€˜mdā€™.

Note that the script will create a custom metadata field of the given name if it doesnā€™t exist already.

Formatting image metadata

Most of the image metadata are stored as strings that the script uses as they are. However, in some cases, the image metadata have to be transformed so that they can be stored in a meaningful way in DT. The object conversionFunctions at the top of the script takes care of that. It specifies a function name for those metadata tags that have to be converted from their original format. For example,

const conversionFunctions = {
  'DateTimeOriginal': convertDate,
  'creation' : convertDate,

converts the date string stored in DateTimeOriginal (EXIF) and creation (Image Events) into a JavaScript Date object that can then be directly assigned to the recordā€™s createDate property.

Other conversion functions convert numerical representations for the exposure program and metering mode to textual values, get the ISO speed value or the name of the color profile used.

Dry-running the script

As it stands, the script will only output its possible actions to DTā€™s log window like this:

LensSpecification:	 '9-37mm f/0-0'	=> mdLensSpecification
FNumber:	 '2.8'	=> mdFNumber
PixelYDimension:	 '5472'	=> mdHeight
ApertureValue:	 '2.970854'	=> mdAperture
ā€¦

The text before the colon is the name of the EXIF or Image Events tag. The text in single quotes is the value of this metadata field, possibly after transformation into a string (like in the case of LensSpecification). The text after the => is the name of the DT custom metadata field, prefixed with ā€˜mdā€™. So, the value for LensSpecification is stored in the custom metadata field of the same name, and the PixelYDimension is stored in the custom metadata field Height.

Wet-run: Adding metadata to DT

To add the image metadata to the DT recordā€™s custom metadata fields, you have to change the value of testRun at the very top of the script to false. This will also stop all output to DTā€™s log window. You can run the script from Script Editor after youā€™ve copied it here and pasted it there. Alternatively, save the code to a file like metadata.js and execute it with osascript -l JavaScript metadata.js in Terminal. Finally, you can save the script from Script Editor in a file with the extension scpt and add that to your DT scripting library (see the fine DT manual for details on that).

The code

Click on arrow to reveal script
ObjC.import('AppKit');

/*
  If testRun is true, 
    the image metadata tags, their values and the target custom metadata fields or record properties 
    are written to DT's log window only ā€“ no DT data is added or modified
  If testRun is false, 
     the image metadata is written to the DT custom metadata fields or record properties as
     defined by 'mapping' and 'conversionFunctions'
*/
      
const testRun = true; 

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,
};


(() => {
  const app = Application("DEVONthink 3")
  app.includeStandardAdditions = true;
  const recs = app.selectedRecords();
  let metadata;
  recs.forEach(r => {
    const p = r.path();
    if (!(r.type() === 'picture' && /\.jpe?g$/i.test(p))) 
      return;
    metadata = readMetadata(p);
    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) {
          app.logMessage(`${k}:\t '${value}'\t=> ${target}`);
        } else {
          updateRecord(app, r, target, value);
        }
      }
    })
    if (testRun) app.logMessage(`\n***\n`)
  })
})()

function updateRecord(app, record, target, value) {
  if (/^md/.test(target)) {
    app.addCustomMetaData(value, {for: target, to: record})
  } else {
    record[target] = value;
  }
}
/* Get metadata from EXIF and Image Events */
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];
}

Room for improvement

To get a lot more metadata from the image (e.g. the location coordinates, author and titleā€¦), one could forego the cumbersome scripting of EXIF dictionaries and Image Events using the Spotlight command mdls and filtering its output. However, this spotlight metadata is removed on import of the image to DT by macOS.

With a different approach, Spotlight metadata could be made available to DT. A script would have to work with images selected in the Finder (possibly providing a file selection dialog). It would then for every file

  • retrieve all its relevant metadata using mdls
  • import the image into DT
  • add the metadata to the newly created record in DT
4 Likes