Script: Getting _all_ metadata from images

I fiddled around a bit with a script to retrieve all metadata from images (i.e. EXIF, EXIF auxiliary, GPS, IPTC – whatever there is). It’s pretty rough right now, and I wanted to know if anyone here is interested in this stuff. Not worth cleaning it up and posting if it’s useless for you gals and guys.

I’m asking because people were occasionally asking in the past to use image metadata in DT (exceeding those that DT supports out of the box, that is).

2 Likes

I’d like to see your approach.

Since there are at least two people who like to see the stuff… The sample code converts image metadata into DT custom metadata key-value pairs.

Edit: Minor changes to the code

ObjC.import('Foundation');
ObjC.import('ImageIO');
(() => {
  const app = Application("DEVONthink 3");
  app.selectedRecords.whose({_match: [ObjectSpecifier().type, "picture"]})().forEach(r => {
    /* Loop over all selected images:
    get filesystem URL from record's path
    get CoreGraphics image from URL
    get image properties from CG image as a CFDictionary
    convert the CFDicationary to an NSDictionary using an XML property list
    */   
    const error = Ref();
    const fileURL = $.CFURLCreateWithFileSystemPath(null,$(r.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);

This is the main part: It loops over all selected records, ignoring everything that is not an image and then retrieves the image’s properties, storing them in propertyDict, which is an NSDictionary.

Side note: Apple’s documentation is a mess. They won’t tell you how to do all that, you have to search the Net like crazy. Where I found the trick with the property list serialization ( CFArray to NSArray - Sketch Developers) that I shamelessly stole

This dictionary contains some simple key-value pairs and possibly some other dictionaries, name (weirdly) {Exif}, '{TIFF}, {GPS}` and so on. What are these people smoking that tells them to use curly brackets in dictionary keys? Anyway.

Once we have the dictionary, we have to dissect it:

let result = {};
Object.keys(propertyDict.js).forEach(k => {
    const match = k.match(/\{(.*)\}/);

    if (! match ) {
        /* "Normal" keys, i.e. not dictionaries for EXIF, TIFF, GPS ... */
        const r = mapToMetadata(k, propertyDict.js[k], null);
      /* If result is defined, its first element is the metadata key, the second element its value */
        if (r) result[r[0]] = r[1];
    } else {
        /* Sub-dictionary like EXIF, TIFF, GPS - map to metadata in passing the dict name */
        const subDict = propertyDict.js[k].js; /* JavaScript object */
        //        console.log(subDict);
        Object.keys(subDict).forEach(subKey => {
          const r = mapToMetadata(subKey, subDict[subKey], match[1]);
          if (r) result[r[0]] = r[1];
        })
      }
})

propertyDict.js converts the NSDictionary to a JavaScript object. Which is then passed to Object.keys to get all its property names (“keys”) as an array. The code uses forEach to loop over these keys and concludes that one containing curly brackets indicates a sub-dictionary. All simple-key value pairs are passed to the function mapToMetadata (see below). For sub-dictionaries, another Object.keys(…).forEach loop passes their key-value pairs to mapToMetadata together with the name of the sub-dictionary.

mapToMetadata takes a key-value pair and returns an array consisting of a metadata name and its value. If the original value is a simple one (i.e. a number or a string), it is returned as is. Otherwise, a conversion function massages it into something useful.

// Truncated version!
function mapToMetadata(key, value, dict) { 
  const defaultMap = { /* mapping for simple metadata outside of EXIF, IPTC, GPS etc */
    PixelWidth: 'mdwidth',
    PixelHeight: 'mdheight',
 … }
  const subMaps = {
    Exif: {
      DateTimeOriginal: 'mdoriginalDate',
      DateTimeDigitized: 'mddigitizedDate',
…    
},
    TIFF: {
      ResolutionUnit: 'md_resolutionUnit',
      XResolution: 'mdxresolution',
      YResolution: 'mdyresolution',
…   
 },
    GPS: {},
    HEIC: {},
  }
  const conversionFunctions = {
…
}
  };
  
 const mdkey = ( () => {
    if (dict) {
      return (dict in subMaps && key in subMaps[dict]) ? subMaps[dict][key] : undefined;
    } else {
      return key in defaultMap ? defaultMap[key] : undefined;
    }
  })()
  const convert = mdkey && mdkey in conversionFunctions ? conversionFunctions[mdkey] : undefined;
  try {
    if (mdkey) {
      const v = convert ? convert(value) : value.js;
      return [mdkey, v];
    } else {
      console.log(`${key}: ${convert ? convert(value): value.js} ${dict || ''} `);
    }
  } catch (e) {
    console.log(`No direct JS conversion for ${key} : ${value}`);
  }
}

Two objects map the original keys to custom metadata names: defaultMap and subMaps. The latter contains sub-mapping for the EXIF, TIFF, GPS etc. dictionaries. Some metadata is recorded several times in different places, like the pixel width/height and the orientation. Those will only be mapped once, the other(s) are ignored.

The object conversionFunctions maps custom metadata names to conversion functions. For example, a md_subjectArea value is an array of two to four integers. The corresponding conversion function

mdsubjectArea: function(value) {
      return value.js.map(v => v.js).join(', ');
    }

simply returns a string with these values separated by a comma. And the conversion function for mdFlash returns a string describing if or how the flash was used when the picture was taken.

The array returned by mapToMetadata can be used to set the custom metadata for the current record, like so

Object.keys(metadata).forEach(k => 
    app.addCustomMetadata(metadata[k], {for: k, to: r});
)

Since this could possibly create a huge amount of custom metadata keys, the code is commented out. Note that the code is not complete: I was too lazy to define mappings for a gazillion EXIF, Tiff, GPS, what not fields. So, the script is merely showing how one could go about that.

The complete script is here.
ObjC.import('Foundation');
ObjC.import('ImageIO');
(() => {
  const app = Application("DEVONthink 3");
  app.selectedRecords.whose({_match: [ObjectSpecifier().type, "picture"]})().forEach(r => {
    /* Loop over all selected images:
    get filesystem URL from record's path
    get CoreGraphics image from URL
    get image properties from CG image as a CFDictionary
    convert the CFDicationary to an NSDictionary using an XML property list
    */   
    const error = Ref();
    //const fileURL = $.CFURLCreateWithFileSystemPath(null,$(r.path()),$.kCFURLPOSIXPathStyle, false);
    fileURL = $.CFURLCreateWithFileSystemPath(null,$('/Volumes/Fotos/2021/Abgestützter-Torbogen.jpg'),$.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);
    /* propertyDict is an NSDictionary containing all image metadata */
    
    let result = {};
    /* Loop over all keys in the propertyDict, converting it to a JS object first with .js */

    Object.keys(propertyDict.js).forEach(k => {
      const match = k.match(/\{(.*)\}/);
          if (! match ) {
        /* "Normal" keys, i.e. not dictionaries for EXIF, TIFF, GPS ... */
        const r = mapToMetadata(k, propertyDict.js[k], null);
       /* If r is defined, its first element is the metadata key, the second element its value */
        if (r) result[r[0]] = r[1];
      } else {
        /* Sub-dictionary like EXIF, TIFF, GPS - map to metadata in passing the dict name */
        const subDict = propertyDict.js[k].js; /* JavaScript object */
        Object.keys(subDict).forEach(subKey => {
          const r = mapToMetadata(subKey, subDict[subKey], match[1]);
          if (r) result[r[0]] = r[1];
        })
      }
    })
    Object.keys(result).forEach(k => {
        console.log(`${k}: ${result[k]}`);
      // Add the result as custom metadata to the current record. 
      // Beware: This could create A LOT of custom metadata keys!
      // app.addCustomMetadata(result[k], {for: k, to: r});

    });
    if (result) {
    }
    
    // https://sketchplugins.com/d/1175-cfarray-to-nsarray/6
    
  })
  
})()

/* map value for key to a DT custom metadata property 
NOTE: value is ObjC data, must be converted to JavaScript by this function */

function mapToMetadata(key, value, dict) { 
  const defaultMap = { /* mapping for simple metadata outside of EXIF, IPTC, GPS etc */
    PixelWidth: 'mdwidth',
    PixelHeight: 'mdheight',
    Orientation: 'mdorientation',
    ProfileName: 'mdprofilename',
    ColorModel: 'mdcolormodel',
  }
  const subMaps = {
    Exif: {
      DateTimeOriginal: 'mdoriginalDate',
      DateTimeDigitized: 'mddigitizedDate',
      MeteringMode: 'mdmeteringMode',
      BrightnessValue: 'mdbrightness',
      LensMake: 'mdlensMake',
      ExposureTime: 'mdexposureTime',
      FNumber: 'mdfnumber',
      FocalLength: 'mdfocalLength',
      FocalLenIn35mmFilm: 'mdfocalLength35mm',
      SceneCaptureType: 'mdsceneCaptureType',
      SceneType: 'mdsceneType',
      ApertureValue: 'mdaperture',
      ShutterSpeedValue: 'mdshutterSpeed',
      SubjectArea: 'mdsubjectArea',
      LensSpecification: 'mdlensSpecification',
      LensModel: 'mdlensModel',
      ISOSpeedRatings: 'mdISO',
      SensingMethod: 'mdsensingMethod',
      ExposureProgram: 'mdexposureProgram',
      ExposureMode: 'mdexposureMode',
      ExposureBiasValue: 'mdexposureBias',
      Flash: 'mdflash',
      ColorSpace: 'mdcolorSpace',
      WhiteBalance: 'mdwhiteBalance',
      /* PixelXDimension and PixelYDiemsion are duplicates of Width and Height */
    },
    TIFF: {
      ResolutionUnit: 'mdresolutionUnit',
      XResolution: 'mdxresolution',
      YResolution: 'mdyresolution',
      Software: 'mdsoftware',
      TileLength: 'mdtileLength',
      DateTime: 'mdtiffDate',
      /* TIFF Orientation is a duplicate of the standard value */
      Model: 'mdmodel',
      Make: 'mdmake',
    },
    GPS: {
      LatitudeRef: 'mdlatitudeRef',
      Latitude: 'mdlatitude',
      LongitudeRef: 'mdlongitudeRef',
      Longitude: 'mdlongitude',
      Altitude: 'mdaltitude',
      AltitudeRef: 'mdaltitudeRef',
    },
    HEIC: {},
    ExifAux: {
      LensModel: 'mdlensModel',
    },
    IPTC: {
      'Country/PrimaryLocationName': 'mdcountry',
      'City': 'mdcity',
      'Province/State': 'mdprovince',
      'SubLocation': 'mdsublocation',
    }
  }
  const conversionFunctions = {
    mdsubjectArea: function(value) {
      return value.js.map(v => v.js).join(', ');
    },
    mdlensSpecification: function(value) {
      return value.js.map(v => v.js).join(', ');
    },
    mdISO: (value => value.js[0].js),
    mdexposureMode: (value => ['Auto','Manual','Auto bracket'][value.js]),
    mdorientation: (value => 
      [undefined,
        'TOPLEFT',
        'TOPRIGHT',
        'BOTRIGHT',
        'BOTLEFT',
        'LEFTTOP',
        'RIGHTTOP',
        'RIGHTBOT',
        'LEFTBOT'
      ][value.js]),
    mdexposureProgram: (value => 
      ['Not Defined',
        'Manual',
        'Program AE',
        'Aperture-priority AE',
        'Shutter speed priority AE',
        'Creative (Slow speed)',
        'Action (High speed)',
        'Portrait',
        'Landscape',
        'Bulb',
      ][value.js]),
    mdmeteringMode: (value => [
      'Unknown',
      'Average',
      'Center-weighted average',
      'Spot',
      'Multi-spot',
      'Multi-segment',
      'Partial ',
    ][value.js]),
    mdflash: (value => {
      const flashValues = {
        '0x0': 'No Flash',
        '0x1': 'Fired',
        '0x5': 'Fired, Return not detected',
        '0x7': 'Fired, Return detected',
        '0x8': 'On, Did not fire',
        '0x9': 'On, Fired',
        '0xd': 'On, Return not detected',
        '0xf': 'On, Return detected',
        '0x10': 'Off, Did not fire',
        '0x14': 'Off, Did not fire, Return not detected',
        '0x18': 'Auto, Did not fire',
        '0x19': 'Auto, Fired',
        '0x1d': 'Auto, Fired, Return not detected',
        '0x1f': 'Auto, Fired, Return detected',
        '0x20': 'No flash function',
        '0x30': 'Off, No flash function',
        '0x41': 'Fired, Red-eye reduction',
        '0x45': 'Fired, Red-eye reduction, Return not detected',
        '0x47': 'Fired, Red-eye reduction, Return detected',
        '0x49': 'On, Red-eye reduction',
        '0x4d': 'On, Red-eye reduction, Return not detected',
        '0x4f': 'On, Red-eye reduction, Return detected',
        '0x50': 'Off, Red-eye reduction',
        '0x58': 'Auto, Did not fire, Red-eye reduction',
        '0x59': 'Auto, Fired, Red-eye reduction',
        '0x5d': 'Auto, Fired, Red-eye reduction, Return not detected',
        '0x5f': 'Auto, Fired, Red-eye reduction, Return detected',
      };
      const hexVal = '0x'+value.js.toString(16);
      return hexVal in flashValues ? flashValues[hexVal] : undefined;
    }),
    mdcolorSpace: (value => {
      const colorSpace = {
        '1': 'sRGB',
        '2': 'Adobe RGB',
        '65533': 'Wide Gamut RGB', //0xfffd 
        '65554': 'ICC Profile', //0xfffe 
        '65555': 'Uncalibrated', //0xffff
      };
      return colorSpace[value.js];
    }),
    mdwhiteBalace: (value => ['Auto','Manual'][value.js]),
  };
  
  const mdkey = ( () => {
    if (dict) {
      return (dict in subMaps && key in subMaps[dict]) ? subMaps[dict][key] : undefined;
    } else {
      return key in defaultMap ? defaultMap[key] : undefined;
    }
  })()
  const convert = mdkey && mdkey in conversionFunctions ? conversionFunctions[mdkey] : undefined;
  try {
    if (mdkey) {
      const v = convert ? convert(value) : value.js;
      return [mdkey, v];
    } else {
      console.log(`${key}: ${convert ? convert(value): value.js} ${dict || ''} `);
    }
  } catch (e) {
    console.log(`No direct JS conversion for ${key} : ${value}`);
  }
}
1 Like