• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bpatrik / pigallery2 / 8653279319

11 Apr 2024 09:07PM UTC coverage: 65.085% (-0.09%) from 65.174%
8653279319

push

github

web-flow
Merge pull request #879 from grasdk/bug/heic-dimensions

updated image-size dependency to read dimensions from heic file

1546 of 2699 branches covered (57.28%)

Branch coverage included in aggregate %.

2 of 6 new or added lines in 1 file covered. (33.33%)

1 existing line in 1 file now uncovered.

4421 of 6469 relevant lines covered (68.34%)

13330.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.19
/src/backend/model/fileaccess/MetadataLoader.ts
1
import * as fs from 'fs';
1✔
2
import { imageSize } from 'image-size';
1✔
3
import { Config } from '../../../common/config/private/Config';
1✔
4
import { FaceRegion, PhotoMetadata } from '../../../common/entities/PhotoDTO';
5
import { VideoMetadata } from '../../../common/entities/VideoDTO';
6
import { RatingTypes } from '../../../common/entities/MediaDTO';
7
import { Logger } from '../../Logger';
1✔
8
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9
// @ts-ignore
10
import * as exifr from 'exifr';
1✔
11
import { FfprobeData } from 'fluent-ffmpeg';
12
import { FileHandle } from 'fs/promises';
13
import * as util from 'node:util';
1✔
14
import * as path from 'path';
1✔
15
import { Utils } from '../../../common/Utils';
1✔
16
import { FFmpegFactory } from '../FFmpegFactory';
1✔
17
import { ExtensionDecorator } from '../extension/ExtensionDecorator';
1✔
18

19
const LOG_TAG = '[MetadataLoader]';
1✔
20
const ffmpeg = FFmpegFactory.get();
1✔
21

22
export class MetadataLoader {
1✔
23

24
  @ExtensionDecorator(e => e.gallery.MetadataLoader.loadVideoMetadata)
39✔
25
  public static async loadVideoMetadata(fullPath: string): Promise<VideoMetadata> {
1✔
26
    const metadata: VideoMetadata = {
39✔
27
      size: {
28
        width: 1,
29
        height: 1,
30
      },
31
      bitRate: 0,
32
      duration: 0,
33
      creationDate: 0,
34
      fileSize: 0,
35
      fps: 0,
36
    };
37

38
    try {
39✔
39
      const stat = fs.statSync(fullPath);
39✔
40
      metadata.fileSize = stat.size;
39✔
41
      metadata.creationDate = stat.mtime.getTime(); //Default date is file system time of last modification
39✔
42
    } catch (err) {
43
      console.log(err);
×
44
      // ignoring errors
45
    }
46
    try {
39✔
47

48

49
      const data: FfprobeData = await util.promisify<FfprobeData>(
39✔
50
        // wrap to arrow function otherwise 'this' is lost for ffprobe
51
        (cb) => ffmpeg(fullPath).ffprobe(cb)
39✔
52
      )();
53

54
      try {
39✔
55
        for (const stream of data.streams) {
39✔
56
          if (stream.width) {
46✔
57
            metadata.size.width = stream.width;
39✔
58
            metadata.size.height = stream.height;
39✔
59

60
            if (
39✔
61
              Utils.isInt32(parseInt('' + stream.rotation, 10)) &&
50✔
62
              (Math.abs(parseInt('' + stream.rotation, 10)) / 90) % 2 === 1
63
            ) {
64
              // noinspection JSSuspiciousNameCombination
65
              metadata.size.width = stream.height;
11✔
66
              // noinspection JSSuspiciousNameCombination
67
              metadata.size.height = stream.width;
11✔
68
            }
69

70
            if (
39✔
71
              Utils.isInt32(Math.floor(parseFloat(stream.duration) * 1000))
72
            ) {
73
              metadata.duration = Math.floor(
29✔
74
                parseFloat(stream.duration) * 1000
75
              );
76
            }
77

78
            if (Utils.isInt32(parseInt(stream.bit_rate, 10))) {
39✔
79
              metadata.bitRate = parseInt(stream.bit_rate, 10) || null;
29!
80
            }
81
            if (Utils.isInt32(parseInt(stream.avg_frame_rate, 10))) {
39!
82
              metadata.fps = parseInt(stream.avg_frame_rate, 10) || null;
39!
83
            }
84
            if (
39✔
85
              stream.tags !== undefined &&
78✔
86
              typeof stream.tags.creation_time === 'string'
87
            ) {
88
              metadata.creationDate =
29✔
89
                Date.parse(stream.tags.creation_time) ||
29!
90
                metadata.creationDate;
91
            }
92
            break;
39✔
93
          }
94
        }
95

96
        // For some filetypes (for instance Matroska), bitrate and duration are stored in
97
        // the format section, not in the stream section.
98

99
        // Only use duration from container header if necessary (stream duration is usually more accurate)
100
        if (
39✔
101
          metadata.duration === 0 &&
59✔
102
          data.format.duration !== undefined &&
103
          Utils.isInt32(Math.floor(data.format.duration * 1000))
104
        ) {
105
          metadata.duration = Math.floor(data.format.duration * 1000);
10✔
106
        }
107

108
        // Prefer bitrate from container header (includes video and audio)
109
        if (
39!
110
          data.format.bit_rate !== undefined &&
78✔
111
          Utils.isInt32(data.format.bit_rate)
112
        ) {
113
          metadata.bitRate = data.format.bit_rate;
39✔
114
        }
115

116
        if (
39!
117
          data.format.tags !== undefined &&
78✔
118
          typeof data.format.tags.creation_time === 'string'
119
        ) {
120
          metadata.creationDate =
39✔
121
            Date.parse(data.format.tags.creation_time) ||
39!
122
            metadata.creationDate;
123
        }
124

125
        // eslint-disable-next-line no-empty
126
      } catch (err) {
127
        Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath);
×
128
        Logger.silly(err);
×
129
      }
130
      metadata.creationDate = metadata.creationDate || 0;
39!
131

132
      try {
39✔
133
        // search for sidecar and merge metadata
134
        const fullPathWithoutExt = path.join(path.parse(fullPath).dir, path.parse(fullPath).name);
39✔
135
        const sidecarPaths = [
39✔
136
          fullPath + '.xmp',
137
          fullPath + '.XMP',
138
          fullPathWithoutExt + '.xmp',
139
          fullPathWithoutExt + '.XMP',
140
        ];
141

142
        for (const sidecarPath of sidecarPaths) {
39✔
143
          if (fs.existsSync(sidecarPath)) {
156✔
144
            const sidecarData: any = await exifr.sidecar(sidecarPath);
9✔
145
            if (sidecarData !== undefined) {
9!
146
              MetadataLoader.mapMetadata(metadata, sidecarData);
9✔
147
            }
148
          }
149
        }
150
      } catch (err) {
151
        Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath);
×
152
        Logger.silly(err);
×
153
      }
154

155
    } catch (err) {
156
      Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath);
×
157
      Logger.silly(err);
×
158
    }
159

160
    return metadata;
39✔
161
  }
162

163
  private static readonly EMPTY_METADATA: PhotoMetadata = {
1✔
164
    size: { width: 0, height: 0 },
165
    creationDate: 0,
166
    fileSize: 0,
167
  };
168

169
  @ExtensionDecorator(e => e.gallery.MetadataLoader.loadPhotoMetadata)
276✔
170
  public static async loadPhotoMetadata(fullPath: string): Promise<PhotoMetadata> {
1✔
171
    let fileHandle: FileHandle;
172
    const metadata: PhotoMetadata = {
276✔
173
      size: { width: 0, height: 0 },
174
      creationDate: 0,
175
      fileSize: 0,
176
    };
177
    const exifrOptions = {
276✔
178
      tiff: true,
179
      xmp: true,
180
      icc: false,
181
      jfif: false, //not needed and not supported for png
182
      ihdr: true,
183
      iptc: true, 
184
      exif: true,
185
      gps: true,
186
      reviveValues: false, //don't convert timestamps
187
      translateValues: false, //don't translate orientation from numbers to strings etc.
188
      mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged
189
    };
190
    try {
276✔
191
      let bufferSize = Config.Media.photoMetadataSize;
276✔
192
      try {
276✔
193
        const stat = fs.statSync(fullPath);
276✔
194
        metadata.fileSize = stat.size;
276✔
195
        //No reason to make the buffer larger than the actual file
196
        bufferSize = Math.min(Config.Media.photoMetadataSize, metadata.fileSize);
276✔
197
        metadata.creationDate = stat.mtime.getTime();
276✔
198
      } catch (err) {
199
        // ignoring errors
200
      }
201
      try {
276✔
202
        //read the actual image size, don't rely on tags for this
203
        const info = imageSize(fullPath);
276✔
204
        metadata.size = { width: info.width, height: info.height };
276✔
205
      } catch (e) {
206
        //in case of failure, set dimensions to 0 so they may be read via tags
UNCOV
207
        metadata.size = { width: 0, height: 0 };
×
208
      } finally {
209
        if (isNaN(metadata.size.width) || metadata.size.width == null) {
276!
NEW
210
          metadata.size.width = 0;
×
211
        }
212
        if (isNaN(metadata.size.height) || metadata.size.height == null) {
276!
NEW
213
          metadata.size.height = 0;
×
214
        }
215
      }
216

217

218
      const data = Buffer.allocUnsafe(bufferSize);
276✔
219
      fileHandle = await fs.promises.open(fullPath, 'r');
276✔
220
      try {
276✔
221
        await fileHandle.read(data, 0, bufferSize, 0);
276✔
222
      } catch (err) {
223
        Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath);
×
224
        console.error(err);
×
225
        return MetadataLoader.EMPTY_METADATA;
×
226
      } finally {
227
        await fileHandle.close();
276✔
228
      }
229
      try {
276✔
230
        try {
276✔
231
          const exif = await exifr.parse(data, exifrOptions);
276✔
232
          MetadataLoader.mapMetadata(metadata, exif);
267✔
233
        } catch (err) {
234
          // ignoring errors
235
        }
236

237
        try {
276✔
238
          // search for sidecar and merge metadata
239
          const fullPathWithoutExt = path.join(path.parse(fullPath).dir, path.parse(fullPath).name);
276✔
240
          const sidecarPaths = [
276✔
241
            fullPath + '.xmp',
242
            fullPath + '.XMP',
243
            fullPathWithoutExt + '.xmp',
244
            fullPathWithoutExt + '.XMP',
245
          ];
246

247
          for (const sidecarPath of sidecarPaths) {
276✔
248
            if (fs.existsSync(sidecarPath)) {
1,104✔
249
              const sidecarData: any = await exifr.sidecar(sidecarPath, exifrOptions);
27✔
250
              if (sidecarData !== undefined) {
27!
251
                MetadataLoader.mapMetadata(metadata, sidecarData);
27✔
252
              }
253
            }
254
          }
255
        } catch (err) {
256
          Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath);
×
257
          Logger.silly(err);
×
258
        }
259
        if (!metadata.creationDate) {
276✔
260
          // creationDate can be negative, when it was created before epoch (1970)
261
          metadata.creationDate = 0;
1✔
262
        }
263
      } catch (err) {
264
        Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath);
×
265
        console.error(err);
×
266
        return MetadataLoader.EMPTY_METADATA;
×
267
      }
268
    } catch (err) {
269
      Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath);
×
270
      console.error(err);
×
271
      return MetadataLoader.EMPTY_METADATA;
×
272
    }
273
    return metadata;
276✔
274
  }
275

276
  private static mapMetadata(metadata: PhotoMetadata, exif: any) {
277
    //replace adobe xap-section with xmp to reuse parsing
278
    if (Object.hasOwn(exif, 'xap')) {
303✔
279
      exif['xmp'] = exif['xap'];
22✔
280
      delete exif['xap'];
22✔
281
    }
282
    const orientation = MetadataLoader.getOrientation(exif);
301✔
283
    MetadataLoader.mapImageDimensions(metadata, exif, orientation);
301✔
284
    MetadataLoader.mapKeywords(metadata, exif);
301✔
285
    MetadataLoader.mapTitle(metadata, exif);
301✔
286
    MetadataLoader.mapCaption(metadata, exif);
301✔
287
    MetadataLoader.mapTimestampAndOffset(metadata, exif);
301✔
288
    MetadataLoader.mapCameraData(metadata, exif);
301✔
289
    MetadataLoader.mapGPS(metadata, exif);
301✔
290
    MetadataLoader.mapToponyms(metadata, exif);
301✔
291
    MetadataLoader.mapRating(metadata, exif);
301✔
292
    if (Config.Faces.enabled) {
301!
293
      MetadataLoader.mapFaces(metadata, exif, orientation);
301✔
294
    }
295

296
  }
297
  private static getOrientation(exif: any): number {
298
    let orientation = 1; //Orientation 1 is normal
301✔
299
    if (exif.ifd0?.Orientation != undefined) {
301✔
300
      orientation = parseInt(exif.ifd0.Orientation as any, 10) as number;
207✔
301
    }
302
    return orientation;
301✔
303
  }
304

305
  private static mapImageDimensions(metadata: PhotoMetadata, exif: any, orientation: number) {
306
    if (metadata.size.width <= 0) {
301!
NEW
307
      metadata.size.width = exif.ifd0?.ImageWidth || exif.exif?.ExifImageWidth || metadata.size.width;
×
308
    }
309
    if (metadata.size.height <= 0) {
301!
NEW
310
      metadata.size.height = exif.ifd0?.ImageHeight || exif.exif?.ExifImageHeight || metadata.size.height;
×
311
    }
312
    metadata.size.height = Math.max(metadata.size.height, 1); //ensure height dimension is positive
301✔
313
    metadata.size.width = Math.max(metadata.size.width, 1); //ensure width  dimension is positive
301✔
314

315
    //we need to switch width and height for images that are rotated sideways
316
    if (4 < orientation) { //Orientation is sideways (rotated 90% or 270%)
301✔
317
      // noinspection JSSuspiciousNameCombination
318
      const height = metadata.size.width;
27✔
319
      // noinspection JSSuspiciousNameCombination
320
      metadata.size.width = metadata.size.height;
27✔
321
      metadata.size.height = height;
27✔
322
    }
323
  }
324

325
  private static mapKeywords(metadata: PhotoMetadata, exif: any) {
326
    if (exif.dc &&
301✔
327
      exif.dc.subject &&
328
      exif.dc.subject.length > 0) {
329
      const subj = Array.isArray(exif.dc.subject) ? exif.dc.subject : [exif.dc.subject];
184✔
330
      if (metadata.keywords === undefined) {
184✔
331
        metadata.keywords = [];
168✔
332
      }
333
      for (const kw of subj) {
184✔
334
        if (metadata.keywords.indexOf(kw) === -1) {
382✔
335
          metadata.keywords.push(kw);
376✔
336
        }
337
      }
338
    }
339
    if (exif.iptc &&
301✔
340
      exif.iptc.Keywords &&
341
      exif.iptc.Keywords.length > 0) {
342
      const subj = Array.isArray(exif.iptc.Keywords) ? exif.iptc.Keywords : [exif.iptc.Keywords];
126✔
343
      if (metadata.keywords === undefined) {
126✔
344
        metadata.keywords = [];
22✔
345
      }
346
      for (let kw of subj) {
126✔
347
        kw = Utils.asciiToUTF8(kw);
345✔
348
        if (metadata.keywords.indexOf(kw) === -1) {
345✔
349
          metadata.keywords.push(kw);
122✔
350
        }
351
      }
352
    }
353
  }
354

355
  private static mapTitle(metadata: PhotoMetadata, exif: any) {
356
    metadata.title = exif.dc?.title?.value || Utils.asciiToUTF8(exif.iptc?.ObjectName) || metadata.title || exif.photoshop?.Headline || exif.acdsee?.caption; //acdsee caption holds the title when data is saved by digikam. Used as last resort if iptc and dc do not contain the data
301✔
357
  }
358

359
  private static mapCaption(metadata: PhotoMetadata, exif: any) {
360
    metadata.caption = exif.dc?.description?.value || Utils.asciiToUTF8(exif.iptc?.Caption) || metadata.caption || exif.ifd0?.ImageDescription || exif.exif?.UserComment?.value || exif.Iptc4xmpCore?.ExtDescrAccessibility?.value ||exif.acdsee?.notes;
301✔
361
  }
362

363
  private static mapTimestampAndOffset(metadata: PhotoMetadata, exif: any) {
364
    metadata.creationDate = Utils.timestampToMS(exif?.photoshop?.DateCreated, null) ||
301✔
365
    Utils.timestampToMS(exif?.xmp?.CreateDate, null) ||
366
    Utils.timestampToMS(exif?.xmp?.ModifyDate, null) ||
367
    Utils.timestampToMS(Utils.toIsoTimestampString(exif?.iptc?.DateCreated, exif?.iptc?.TimeCreated), null) ||
368
    metadata.creationDate;
369

370
    metadata.creationDateOffset = Utils.timestampToOffsetString(exif?.photoshop?.DateCreated) ||
301✔
371
    Utils.timestampToOffsetString(exif?.xmp?.CreateDate) ||
372
    metadata.creationDateOffset;
373
    if (exif.exif) {
301✔
374
      let offset = undefined;
239✔
375
      //Preceedence of dates: exif.DateTimeOriginal, exif.CreateDate, ifd0.ModifyDate, ihdr["Creation Time"], xmp.MetadataDate, file system date
376
      //Filesystem is the absolute last resort, and it's hard to write tests for, since file system dates are changed on e.g. git clone.
377
      if (exif.exif.DateTimeOriginal) {
239✔
378
        //DateTimeOriginal is when the camera shutter closed
379
        offset = exif.exif.OffsetTimeOriginal; //OffsetTimeOriginal is the corresponding offset
168✔
380
        if (!offset) { //Find offset among other options if possible
168✔
381
          offset = exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || Utils.getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps);
104✔
382
        }
383
        metadata.creationDate = Utils.timestampToMS(exif.exif.DateTimeOriginal, offset);
168✔
384
      } else if (exif.exif.CreateDate) { //using else if here, because DateTimeOriginal has preceedence
71✔
385
        //Create is when the camera wrote the file (typically within the same ms as shutter close)
386
        offset = exif.exif.OffsetTimeDigitized; //OffsetTimeDigitized is the corresponding offset
10✔
387
        if (!offset) { //Find offset among other options if possible
10!
388
          offset = exif.exif.OffsetTimeOriginal || exif.exif.OffsetTime || Utils.getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps);
10!
389
        }
390
        metadata.creationDate = Utils.timestampToMS(exif.exif.CreateDate, offset);
10✔
391
      } else if (exif.ifd0?.ModifyDate) { //using else if here, because DateTimeOriginal and CreatDate have preceedence
61!
392
        offset = exif.exif.OffsetTime; //exif.Offsettime is the offset corresponding to ifd0.ModifyDate
×
393
        if (!offset) { //Find offset among other options if possible
×
394
          offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || Utils.getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps);
×
395
        }
396
        metadata.creationDate = Utils.timestampToMS(exif.ifd0.ModifyDate, offset);
×
397
      } else if (exif.ihdr && exif.ihdr["Creation Time"]) {// again else if (another fallback date if the good ones aren't there) {
61✔
398
        const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || Utils.getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps);
10✔
399
        metadata.creationDate = Utils.timestampToMS(exif.ihdr["Creation Time"], any_offset);
10✔
400
        offset = any_offset;
10✔
401
      } else if (exif.xmp?.MetadataDate) {// again else if (another fallback date if the good ones aren't there - metadata date is probably later than actual creation date, but much better than file time) {
51✔
402
        const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || Utils.getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps);
28✔
403
        metadata.creationDate = Utils.timestampToMS(exif.xmp.MetadataDate, any_offset);
28✔
404
        offset = any_offset;
28✔
405
      }
406
      metadata.creationDateOffset = offset || metadata.creationDateOffset;
239✔
407
    }
408
  }
409

410
  private static mapCameraData(metadata: PhotoMetadata, exif: any) {
411
    metadata.cameraData = metadata.cameraData || {};
301✔
412
    metadata.cameraData.make = exif.ifd0?.Make || exif.tiff?.Make || metadata.cameraData.make;
301✔
413

414
    metadata.cameraData.model = exif.ifd0?.Model || exif.tiff?.Model || metadata.cameraData.model;
301✔
415

416
    metadata.cameraData.lens = exif.exif?.LensModel || exif.exifEX?.LensModel || metadata.cameraData.lens;
301✔
417

418
    if (exif.exif) {
301✔
419
      if (Utils.isUInt32(exif.exif.ISO)) {
239✔
420
        metadata.cameraData.ISO = parseInt('' + exif.exif.ISO, 10);
102✔
421
      }
422
      if (Utils.isFloat32(exif.exif.FocalLength)) {
239✔
423
        metadata.cameraData.focalLength = parseFloat(
102✔
424
          '' + exif.exif.FocalLength
425
        );
426
      }
427
      if (Utils.isFloat32(exif.exif.ExposureTime)) {
239✔
428
        metadata.cameraData.exposure = parseFloat(
102✔
429
          parseFloat('' + exif.exif.ExposureTime).toFixed(6)
430
        );
431
      }
432
      if (Utils.isFloat32(exif.exif.FNumber)) {
239✔
433
        metadata.cameraData.fStop = parseFloat(
102✔
434
          parseFloat('' + exif.exif.FNumber).toFixed(2)
435
        );
436
      }
437
    }
438
    Utils.removeNullOrEmptyObj(metadata.cameraData);
301✔
439
    if (Object.keys(metadata.cameraData).length === 0) {
301✔
440
      delete metadata.cameraData;
139✔
441
    }
442
  }
443

444
  private static mapGPS(metadata: PhotoMetadata, exif: any) {
445
    try {
301✔
446
    if (exif.gps || (exif.exif && exif.exif.GPSLatitude && exif.exif.GPSLongitude)) {
301✔
447
      metadata.positionData = metadata.positionData || {};
110✔
448
      metadata.positionData.GPSData = metadata.positionData.GPSData || {};
110✔
449

450
      metadata.positionData.GPSData.longitude = Utils.isFloat32(exif.gps?.longitude) ? exif.gps.longitude : Utils.xmpExifGpsCoordinateToDecimalDegrees(exif.exif.GPSLongitude);
110✔
451
      metadata.positionData.GPSData.latitude = Utils.isFloat32(exif.gps?.latitude) ? exif.gps.latitude : Utils.xmpExifGpsCoordinateToDecimalDegrees(exif.exif.GPSLatitude);
110✔
452

453
      if (metadata.positionData.GPSData.longitude !== undefined) {
110✔
454
        metadata.positionData.GPSData.longitude = parseFloat(metadata.positionData.GPSData.longitude.toFixed(6))
99✔
455
      }
456
      if (metadata.positionData.GPSData.latitude !== undefined) {
110✔
457
        metadata.positionData.GPSData.latitude = parseFloat(metadata.positionData.GPSData.latitude.toFixed(6))
99✔
458
      }
459
    }
460
    } catch (err) {
461
      Logger.error(LOG_TAG, 'Error during reading of GPS data: ' + err);
×
462
    } finally {
463
      if (metadata.positionData) {
301✔
464
        Utils.removeNullOrEmptyObj(metadata.positionData);
111✔
465
        if (Object.keys(metadata.positionData).length === 0) {
111✔
466
          delete metadata.positionData;
11✔
467
        }
468
      }
469
    }
470
  }
471

472
  private static mapToponyms(metadata: PhotoMetadata, exif: any) {
473
    //Function to convert html code for special characters into their corresponding character (used in exif.photoshop-section)
474

475
    metadata.positionData = metadata.positionData || {};
301✔
476
    metadata.positionData.country = Utils.asciiToUTF8(exif.iptc?.Country) || Utils.decodeHTMLChars(exif.photoshop?.Country);
301✔
477
    metadata.positionData.state = Utils.asciiToUTF8(exif.iptc?.State) || Utils.decodeHTMLChars(exif.photoshop?.State);
301✔
478
    metadata.positionData.city = Utils.asciiToUTF8(exif.iptc?.City) || Utils.decodeHTMLChars(exif.photoshop?.City);
301✔
479
    if (metadata.positionData) {
301!
480
      Utils.removeNullOrEmptyObj(metadata.positionData);
301✔
481
      if (Object.keys(metadata.positionData).length === 0) {
301✔
482
        delete metadata.positionData;
180✔
483
      }
484
    }
485
  }
486

487
  private static mapRating(metadata: PhotoMetadata, exif: any) {
488
    if (exif.xmp &&
301✔
489
      exif.xmp.Rating !== undefined) {
490
      const rting = Math.round(exif.xmp.Rating);
95✔
491
      if (rting <= 0) {
95✔
492
        //We map all ratings below 0 to 0. Lightroom supports value -1, but most other tools (including this) don't.
493
        //Rating 0 means "unrated" according to adobe's spec, so we delete the attribute in pigallery for the same effect
494
        delete metadata.rating;
28✔
495
      } else if (rting > 5) { //map all ratings above 5 to 5
67!
496
        metadata.rating = 5;
×
497
      } else {
498
        metadata.rating = (rting as RatingTypes);
67✔
499
      }
500
    } 
501
  }
502

503
  private static mapFaces(metadata: PhotoMetadata, exif: any, orientation: number) {
504
    //xmp."mwg-rs" section
505
    if (exif["mwg-rs"] &&
301✔
506
      exif["mwg-rs"].Regions) {
507
      const faces: FaceRegion[] = [];
114✔
508
      const regionListVal = Array.isArray(exif["mwg-rs"].Regions.RegionList) ? exif["mwg-rs"].Regions.RegionList : [exif["mwg-rs"].Regions.RegionList];
114✔
509
      if (regionListVal) {
114!
510
        for (const regionRoot of regionListVal) {
114✔
511
          let type;
512
          let name;
513
          let box;
514
          const createFaceBox = (
202✔
515
            w: string,
516
            h: string,
517
            x: string,
518
            y: string
519
          ) => {
520
            if (4 < orientation) { //roation is sidewards (90 or 270 degrees)
202✔
521
              [x, y] = [y, x];
12✔
522
              [w, h] = [h, w];
12✔
523
            }
524
            let swapX = 0;
202✔
525
            let swapY = 0;
202✔
526
            switch (orientation) {
202✔
527
              case 2: //TOP RIGHT (Mirror horizontal):
528
              case 6: //RIGHT TOP (Rotate 90 CW)
529
                swapX = 1;
6✔
530
                break;
6✔
531
              case 3: // BOTTOM RIGHT (Rotate 180)
532
              case 7: // RIGHT BOTTOM (Mirror horizontal and rotate 90 CW)
533
                swapX = 1;
6✔
534
                swapY = 1;
6✔
535
                break;
6✔
536
              case 4: //BOTTOM_LEFT (Mirror vertical)
537
              case 8: //LEFT_BOTTOM (Rotate 270 CW)
538
                swapY = 1;
6✔
539
                break;
6✔
540
            }
541
            // converting ratio to px
542
            return {
202✔
543
              width: Math.round(parseFloat(w) * metadata.size.width),
544
              height: Math.round(parseFloat(h) * metadata.size.height),
545
              left: Math.round(Math.abs(parseFloat(x) - swapX) * metadata.size.width),
546
              top: Math.round(Math.abs(parseFloat(y) - swapY) * metadata.size.height),
547
            };
548
          };
549
          /* Adobe Lightroom based face region structure */
550
          if (
202!
551
            regionRoot &&
404!
552
            regionRoot['rdf:Description'] &&
553
            regionRoot['rdf:Description'] &&
554
            regionRoot['rdf:Description']['mwg-rs:Area']
555
          ) {
556
            const region = regionRoot['rdf:Description'];
×
557
            const regionBox = region['mwg-rs:Area'].attributes;
×
558

559
            name = region['mwg-rs:Name'];
×
560
            type = region['mwg-rs:Type'];
×
561
            box = createFaceBox(
×
562
              regionBox['stArea:w'],
563
              regionBox['stArea:h'],
564
              regionBox['stArea:x'],
565
              regionBox['stArea:y']
566
            );
567
            /* Load exiftool edited face region structure, see github issue #191 */
568
          } else if (
202!
569
            regionRoot &&
808✔
570
            regionRoot.Name &&
571
            regionRoot.Type &&
572
            regionRoot.Area
573
          ) {
574
            const regionBox = regionRoot.Area;
202✔
575
            name = regionRoot.Name;
202✔
576
            type = regionRoot.Type;
202✔
577
            box = createFaceBox(
202✔
578
              regionBox.w,
579
              regionBox.h,
580
              regionBox.x,
581
              regionBox.y
582
            );
583
          }
584

585
          if (type !== 'Face' || !name) {
202!
586
            continue;
×
587
          }
588

589
          // convert center base box to corner based box
590
          box.left = Math.round(Math.max(0, box.left - box.width / 2));
202✔
591
          box.top = Math.round(Math.max(0, box.top - box.height / 2));
202✔
592

593

594
          faces.push({ name, box });
202✔
595
        }
596
      }
597
      if (faces.length > 0) {
114!
598
        metadata.faces = faces; // save faces
114✔
599
        if (Config.Faces.keywordsToPersons) {
114!
600
          // remove faces from keywords
601
          metadata.faces.forEach((f) => {
114✔
602
            const index = metadata.keywords.indexOf(f.name);
202✔
603
            if (index !== -1) {
202✔
604
              metadata.keywords.splice(index, 1);
180✔
605
            }
606
          });
607
        }
608
      }
609
    }
610
  }
611
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc