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

bpatrik / pigallery2 / 17693597668

13 Sep 2025 07:33AM UTC coverage: 66.497% (-0.008%) from 66.505%
17693597668

push

github

bpatrik
let exifr read the file insteadof the app guessing the size.  #277

1322 of 2243 branches covered (58.94%)

Branch coverage included in aggregate %.

29 of 30 new or added lines in 1 file covered. (96.67%)

1 existing line in 1 file now uncovered.

4835 of 7016 relevant lines covered (68.91%)

4358.92 hits per line

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

89.22
/src/backend/model/fileaccess/MetadataLoader.ts
1
import * as fs from 'fs';
1✔
2

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 * as util from 'node:util';
1✔
13
import * as path from 'path';
1✔
14
import {Utils} from '../../../common/Utils';
1✔
15
import {FFmpegFactory} from '../FFmpegFactory';
1✔
16
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
1✔
17
import {DateTags} from './MetadataCreationDate';
1✔
18

19
const {imageSizeFromFile} = require('image-size/fromFile');
1✔
20
const LOG_TAG = '[MetadataLoader]';
1✔
21
const ffmpeg = FFmpegFactory.get();
1✔
22

23
export class MetadataLoader {
1✔
24

25
  private static readonly EMPTY_METADATA: PhotoMetadata = {
1✔
26
    size: {width: 0, height: 0},
27
    creationDate: 0,
28
    fileSize: 0,
29
  };
30

31
  @ExtensionDecorator(e => e.gallery.MetadataLoader.loadVideoMetadata)
39✔
32
  public static async loadVideoMetadata(fullPath: string): Promise<VideoMetadata> {
1✔
33
    const metadata: VideoMetadata = {
39✔
34
      size: {
35
        width: 1,
36
        height: 1,
37
      },
38
      bitRate: 0,
39
      duration: 0,
40
      creationDate: 0,
41
      fileSize: 0,
42
      fps: 0,
43
    };
44

45
    try {
39✔
46
      const stat = fs.statSync(fullPath);
39✔
47
      metadata.fileSize = stat.size;
39✔
48
      metadata.creationDate = stat.mtime.getTime(); //Default date is file system time of last modification
39✔
49
    } catch (err) {
50
      console.log(err);
×
51
      // ignoring errors
52
    }
53
    try {
39✔
54

55

56
      const data: FfprobeData = await util.promisify<FfprobeData>(
39✔
57
        // wrap to arrow function otherwise 'this' is lost for ffprobe
58
        (cb) => ffmpeg(fullPath).ffprobe(cb)
39✔
59
      )();
60

61
      try {
39✔
62
        for (const stream of data.streams) {
39✔
63
          if (stream.width) {
46✔
64
            metadata.size.width = stream.width;
39✔
65
            metadata.size.height = stream.height;
39✔
66

67
            if (
39✔
68
              Utils.isInt32(parseInt('' + stream.rotation, 10)) &&
50✔
69
              (Math.abs(parseInt('' + stream.rotation, 10)) / 90) % 2 === 1
70
            ) {
71
              // noinspection JSSuspiciousNameCombination
72
              metadata.size.width = stream.height;
11✔
73
              // noinspection JSSuspiciousNameCombination
74
              metadata.size.height = stream.width;
11✔
75
            }
76

77
            if (
39✔
78
              Utils.isInt32(Math.floor(parseFloat(stream.duration) * 1000))
79
            ) {
80
              metadata.duration = Math.floor(
29✔
81
                parseFloat(stream.duration) * 1000
82
              );
83
            }
84

85
            if (Utils.isInt32(parseInt(stream.bit_rate, 10))) {
39✔
86
              metadata.bitRate = parseInt(stream.bit_rate, 10) || null;
29!
87
            }
88
            if (Utils.isInt32(parseInt(stream.avg_frame_rate, 10))) {
39✔
89
              metadata.fps = parseInt(stream.avg_frame_rate, 10) || null;
39!
90
            }
91
            if (
39✔
92
              stream.tags !== undefined &&
78✔
93
              typeof stream.tags.creation_time === 'string'
94
            ) {
95
              metadata.creationDate =
29✔
96
                Date.parse(stream.tags.creation_time) ||
29!
97
                metadata.creationDate;
98
            }
99
            break;
39✔
100
          }
101
        }
102

103
        // For some filetypes (for instance Matroska), bitrate and duration are stored in
104
        // the format section, not in the stream section.
105

106
        // Only use duration from container header if necessary (stream duration is usually more accurate)
107
        if (
39✔
108
          metadata.duration === 0 &&
59✔
109
          data.format.duration !== undefined &&
110
          Utils.isInt32(Math.floor(data.format.duration * 1000))
111
        ) {
112
          metadata.duration = Math.floor(data.format.duration * 1000);
10✔
113
        }
114

115
        // Prefer bitrate from container header (includes video and audio)
116
        if (
39✔
117
          data.format.bit_rate !== undefined &&
78✔
118
          Utils.isInt32(data.format.bit_rate)
119
        ) {
120
          metadata.bitRate = data.format.bit_rate;
39✔
121
        }
122

123
        if (
39✔
124
          data.format.tags !== undefined &&
78✔
125
          typeof data.format.tags.creation_time === 'string'
126
        ) {
127
          metadata.creationDate =
39✔
128
            Date.parse(data.format.tags.creation_time) ||
39!
129
            metadata.creationDate;
130
        }
131

132
        // eslint-disable-next-line no-empty
133
      } catch (err) {
134
        Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath);
×
135
        Logger.silly(err);
×
136
      }
137
      metadata.creationDate = metadata.creationDate || 0;
39!
138

139
      try {
39✔
140
        // search for sidecar and merge metadata
141
        const fullPathWithoutExt = path.join(path.parse(fullPath).dir, path.parse(fullPath).name);
39✔
142
        const sidecarPaths = [
39✔
143
          fullPath + '.xmp',
144
          fullPath + '.XMP',
145
          fullPathWithoutExt + '.xmp',
146
          fullPathWithoutExt + '.XMP',
147
        ];
148

149
        for (const sidecarPath of sidecarPaths) {
39✔
150
          if (fs.existsSync(sidecarPath)) {
156✔
151
            const sidecarData: any = await exifr.sidecar(sidecarPath);
9✔
152
            if (sidecarData !== undefined) {
9✔
153
              MetadataLoader.mapMetadata(metadata, sidecarData);
9✔
154
            }
155
          }
156
        }
157
      } catch (err) {
158
        Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath);
×
159
        Logger.silly(err);
×
160
      }
161

162
    } catch (err) {
163
      Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath);
×
164
      Logger.silly(err);
×
165
    }
166

167
    return metadata;
39✔
168
  }
169

170
  @ExtensionDecorator(e => e.gallery.MetadataLoader.loadPhotoMetadata)
304✔
171
  public static async loadPhotoMetadata(fullPath: string): Promise<PhotoMetadata> {
1✔
172
    const metadata: PhotoMetadata = {
304✔
173
      size: {width: 0, height: 0},
174
      creationDate: 0,
175
      fileSize: 0,
176
    };
177
    const exifrOptions = {
304✔
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 {
304✔
191
      try {
304✔
192
        const stat = fs.statSync(fullPath);
304✔
193
        metadata.fileSize = stat.size;
304✔
194
        metadata.creationDate = stat.mtime.getTime();
304✔
195
      } catch (err) {
196
        // ignoring errors
197
      }
198
      try {
304✔
199
        //read the actual image size, don't rely on tags for this
200
        const info = await imageSizeFromFile(fullPath);
304✔
201
        metadata.size = {width: info.width, height: info.height};
304✔
202
      } catch (e) {
203
        //in case of failure, set dimensions to 0 so they may be read via tags
NEW
204
        metadata.size = {width: 0, height: 0};
×
205
      } finally {
206
        if (isNaN(metadata.size.width) || metadata.size.width == null) {
304!
207
          metadata.size.width = 0;
×
208
        }
209
        if (isNaN(metadata.size.height) || metadata.size.height == null) {
304!
210
          metadata.size.height = 0;
×
211
        }
212
      }
213

214
      try {
304✔
215
        try {
304✔
216
          const exif = await exifr.parse(fullPath, exifrOptions);
304✔
217
          MetadataLoader.mapMetadata(metadata, exif);
295✔
218
        } catch (err) {
219
          // ignoring errors
220
        }
221

222
        try {
304✔
223
          // search for sidecar and merge metadata
224
          const fullPathWithoutExt = path.join(path.parse(fullPath).dir, path.parse(fullPath).name);
304✔
225
          const sidecarPaths = [
304✔
226
            fullPath + '.xmp',
227
            fullPath + '.XMP',
228
            fullPathWithoutExt + '.xmp',
229
            fullPathWithoutExt + '.XMP',
230
          ];
231

232
          for (const sidecarPath of sidecarPaths) {
304✔
233
            if (fs.existsSync(sidecarPath)) {
1,155✔
234
              const sidecarData: any = await exifr.sidecar(sidecarPath, exifrOptions);
27✔
235
              if (sidecarData !== undefined) {
27✔
236
                //note that since side cars are loaded last, data loaded here overwrites embedded metadata (in Pigallery2, not in the actual files)
237
                MetadataLoader.mapMetadata(metadata, sidecarData);
27✔
238
                break;
27✔
239
              }
240
            }
241
          }
242
        } catch (err) {
243
          Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath);
×
244
          Logger.silly(err);
×
245
        }
246
        if (!metadata.creationDate) {
304!
247
          // creationDate can be negative, when it was created before epoch (1970)
248
          metadata.creationDate = 0;
×
249
        }
250
      } catch (err) {
251
        Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath);
×
252
        console.error(err);
×
253
        return MetadataLoader.EMPTY_METADATA;
×
254
      }
255
    } catch (err) {
256
      Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath);
×
257
      console.error(err);
×
258
      return MetadataLoader.EMPTY_METADATA;
×
259
    }
260
    return metadata;
304✔
261
  }
262

263
  private static mapMetadata(metadata: PhotoMetadata, exif: any) {
264
    //replace adobe xap-section with xmp to reuse parsing
265
    if (Object.hasOwn(exif, 'xap')) {
331✔
266
      exif['xmp'] = exif['xap'];
22✔
267
      delete exif['xap'];
22✔
268
    }
269
    const orientation = MetadataLoader.getOrientation(exif);
329✔
270
    MetadataLoader.mapImageDimensions(metadata, exif, orientation);
329✔
271
    MetadataLoader.mapKeywords(metadata, exif);
329✔
272
    MetadataLoader.mapTitle(metadata, exif);
329✔
273
    MetadataLoader.mapCaption(metadata, exif);
329✔
274
    MetadataLoader.mapTimestampAndOffset(metadata, exif);
329✔
275
    MetadataLoader.mapCameraData(metadata, exif);
329✔
276
    MetadataLoader.mapGPS(metadata, exif);
329✔
277
    MetadataLoader.mapToponyms(metadata, exif);
329✔
278
    MetadataLoader.mapRating(metadata, exif);
329✔
279
    if (Config.Faces.enabled) {
329✔
280
      MetadataLoader.mapFaces(metadata, exif, orientation);
329✔
281
    }
282

283
  }
284

285
  private static getOrientation(exif: any): number {
286
    let orientation = 1; //Orientation 1 is normal
329✔
287
    if (exif.ifd0?.Orientation != undefined) {
329✔
288
      orientation = parseInt(exif.ifd0.Orientation as any, 10) as number;
218✔
289
    }
290
    return orientation;
329✔
291
  }
292

293
  private static mapImageDimensions(metadata: PhotoMetadata, exif: any, orientation: number) {
294
    if (metadata.size.width <= 0) {
329!
295
      metadata.size.width = exif.ifd0?.ImageWidth || exif.exif?.ExifImageWidth || metadata.size.width;
×
296
    }
297
    if (metadata.size.height <= 0) {
329!
298
      metadata.size.height = exif.ifd0?.ImageHeight || exif.exif?.ExifImageHeight || metadata.size.height;
×
299
    }
300
    metadata.size.height = Math.max(metadata.size.height, 1); //ensure height dimension is positive
329✔
301
    metadata.size.width = Math.max(metadata.size.width, 1); //ensure width  dimension is positive
329✔
302

303
    //we need to switch width and height for images that are rotated sideways
304
    if (4 < orientation) { //Orientation is sideways (rotated 90% or 270%)
329✔
305
      // noinspection JSSuspiciousNameCombination
306
      const height = metadata.size.width;
27✔
307
      // noinspection JSSuspiciousNameCombination
308
      metadata.size.width = metadata.size.height;
27✔
309
      metadata.size.height = height;
27✔
310
    }
311
  }
312

313
  private static mapKeywords(metadata: PhotoMetadata, exif: any) {
314
    if (exif.dc &&
329✔
315
      exif.dc.subject &&
316
      exif.dc.subject.length > 0) {
317
      const subj = Array.isArray(exif.dc.subject) ? exif.dc.subject : [exif.dc.subject];
186✔
318
      if (metadata.keywords === undefined) {
186✔
319
        metadata.keywords = [];
170✔
320
      }
321
      for (const kw of subj) {
186✔
322
        if (metadata.keywords.indexOf(kw) === -1) {
384✔
323
          metadata.keywords.push(kw);
378✔
324
        }
325
      }
326
    }
327
    if (exif.iptc &&
329✔
328
      exif.iptc.Keywords &&
329
      exif.iptc.Keywords.length > 0) {
330
      const subj = Array.isArray(exif.iptc.Keywords) ? exif.iptc.Keywords : [exif.iptc.Keywords];
128✔
331
      if (metadata.keywords === undefined) {
128✔
332
        metadata.keywords = [];
22✔
333
      }
334
      for (let kw of subj) {
128✔
335
        kw = Utils.asciiToUTF8(kw);
347✔
336
        if (metadata.keywords.indexOf(kw) === -1) {
347✔
337
          metadata.keywords.push(kw);
122✔
338
        }
339
      }
340
    }
341
  }
342

343
  private static mapTitle(metadata: PhotoMetadata, exif: any) {
344
    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
329✔
345
  }
346

347
  private static mapCaption(metadata: PhotoMetadata, exif: any) {
348
    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;
329✔
349
  }
350

351
  private static mapTimestampAndOffset(metadata: PhotoMetadata, exif: any) {
352
    //This method looks for date tags matching the priorized list 'DateTags' of 'MetadataCreationDate'
353
    let ts: string, offset: string;
354
    for (let i = 0; i < DateTags.length; i++) {
329✔
355
      const [mainpath, extrapath, extratype] = DateTags[i];
1,620✔
356
      [ts, offset] = extractTSAndOffset(mainpath, extrapath, extratype);
1,620✔
357
      if (ts) {
1,620✔
358
        if (!offset) { //We don't have the offset from the timestamp or from extra tag, let's see if we can find it in another way
290✔
359
          //Check the explicit offset tags. Otherwise calculate from GPS
360
          offset = exif.exif?.OffsetTimeOriginal || exif.exif?.OffsetTimeDigitized || exif.exif?.OffsetTime || Utils.getTimeOffsetByGPSStamp(ts, exif.exif?.GPSTimeStamp, exif.gps);
155✔
361
        }
362
        if (!offset) { //still no offset? let's look for a timestamp with offset in the rest of the DateTags list
290✔
363
          const [tsonly, tsoffset] = Utils.splitTimestampAndOffset(ts);
117✔
364
          for (let j = i + 1; j < DateTags.length; j++) {
117✔
365
            const [exts, exOffset] = extractTSAndOffset(DateTags[j][0], DateTags[j][1], DateTags[j][2]);
1,613✔
366
            if (exts && exOffset && Math.abs(Utils.timestampToMS(tsonly, null) - Utils.timestampToMS(exts, null)) < 30000) {
1,613✔
367
              //if there is an offset and the found timestamp is within 30 seconds of the extra timestamp, we will use the offset from the found timestamp
368
              offset = exOffset;
1✔
369
              break;
1✔
370
            }
371
          }
372
        }
373
        break; //timestamp is found, look no further
290✔
374
      }
375
    }
376
    metadata.creationDate = Utils.timestampToMS(ts, offset) || metadata.creationDate;
329✔
377
    metadata.creationDateOffset = offset || metadata.creationDateOffset;
329✔
378
    //---- End of mapTimestampAndOffset logic ----
379

380
    //---- Helper functions for mapTimestampAndOffset ----
381
    function getValue(exif: any, path: string): any {
382
      const pathElements = path.split('.');
3,528✔
383
      let currentObject: any = exif;
3,528✔
384
      for (const pathElm of pathElements) {
3,528✔
385
        const tmp = currentObject[pathElm];
5,190✔
386
        if (tmp === undefined) {
5,190✔
387
          return undefined;
2,772✔
388
        }
389
        currentObject = tmp;
2,418✔
390
      }
391
      return currentObject;
756✔
392
    }
393

394
    function extractTSAndOffset(mainpath: string, extrapath: string, extratype: string) {
395
      let ts: string | undefined = undefined;
3,233✔
396
      let offset: string | undefined = undefined;
3,233✔
397
      //line below is programmatic way of finding a timestamp in the exif object. For example "xmp.CreateDate", from the DateTags list
398
      //ts = exif.xmp?.CreateDate
399
      ts = getValue(exif, mainpath);
3,233✔
400
      if (ts) {
3,233✔
401
        if (!extratype || extratype == 'O') { //offset can be in the timestamp itself
654✔
402
          [ts, offset] = Utils.splitTimestampAndOffset(ts);
632✔
403
          if (extratype == 'O' && !offset) { //offset in the extra tag and not already extracted from main tag
632✔
404
            offset = getValue(exif, extrapath);
273✔
405
          }
406
        } else if (extratype == 'T') { //date only in main tag, time in the extra tag
22✔
407
          ts = Utils.toIsoTimestampString(ts, getValue(exif, extrapath));
22✔
408
          [ts, offset] = Utils.splitTimestampAndOffset(ts);
22✔
409
        }
410
      }
411
      return [ts, offset];
3,233✔
412
    }
413

414

415
  }
416

417
  private static mapCameraData(metadata: PhotoMetadata, exif: any) {
418
    metadata.cameraData = metadata.cameraData || {};
329✔
419
    metadata.cameraData.make = exif.ifd0?.Make || exif.tiff?.Make || metadata.cameraData.make;
329✔
420

421
    metadata.cameraData.model = exif.ifd0?.Model || exif.tiff?.Model || metadata.cameraData.model;
329✔
422

423
    metadata.cameraData.lens = exif.exif?.LensModel || exif.exifEX?.LensModel || metadata.cameraData.lens;
329✔
424

425
    if (exif.exif) {
329✔
426
      if (Utils.isUInt32(exif.exif.ISO)) {
267✔
427
        metadata.cameraData.ISO = parseInt('' + exif.exif.ISO, 10);
102✔
428
      }
429
      if (Utils.isFloat32(exif.exif.FocalLength)) {
267✔
430
        metadata.cameraData.focalLength = parseFloat(
102✔
431
          '' + exif.exif.FocalLength
432
        );
433
      }
434
      if (Utils.isFloat32(exif.exif.ExposureTime)) {
267✔
435
        metadata.cameraData.exposure = parseFloat(
102✔
436
          parseFloat('' + exif.exif.ExposureTime).toFixed(6)
437
        );
438
      }
439
      if (Utils.isFloat32(exif.exif.FNumber)) {
267✔
440
        metadata.cameraData.fStop = parseFloat(
102✔
441
          parseFloat('' + exif.exif.FNumber).toFixed(2)
442
        );
443
      }
444
    }
445
    Utils.removeNullOrEmptyObj(metadata.cameraData);
329✔
446
    if (Object.keys(metadata.cameraData).length === 0) {
329✔
447
      delete metadata.cameraData;
165✔
448
    }
449
  }
450

451
  private static mapGPS(metadata: PhotoMetadata, exif: any) {
452
    try {
329✔
453
      if (exif.gps || (exif.exif && exif.exif.GPSLatitude && exif.exif.GPSLongitude)) {
329✔
454
        metadata.positionData = metadata.positionData || {};
128✔
455
        metadata.positionData.GPSData = metadata.positionData.GPSData || {};
128✔
456

457
        metadata.positionData.GPSData.longitude = Utils.isFloat32(exif.gps?.longitude) ? exif.gps.longitude : Utils.xmpExifGpsCoordinateToDecimalDegrees(exif.exif.GPSLongitude);
128✔
458
        metadata.positionData.GPSData.latitude = Utils.isFloat32(exif.gps?.latitude) ? exif.gps.latitude : Utils.xmpExifGpsCoordinateToDecimalDegrees(exif.exif.GPSLatitude);
128✔
459

460
        if (metadata.positionData.GPSData.longitude !== undefined) {
128✔
461
          metadata.positionData.GPSData.longitude = parseFloat(metadata.positionData.GPSData.longitude.toFixed(6));
117✔
462
        }
463
        if (metadata.positionData.GPSData.latitude !== undefined) {
128✔
464
          metadata.positionData.GPSData.latitude = parseFloat(metadata.positionData.GPSData.latitude.toFixed(6));
117✔
465
        }
466
      }
467
    } catch (err) {
UNCOV
468
      Logger.error(LOG_TAG, 'Error during reading of GPS data: ' + err);
×
469
    } finally {
470
      if (metadata.positionData) {
329✔
471
        Utils.removeNullOrEmptyObj(metadata.positionData);
129✔
472
        if (Object.keys(metadata.positionData).length === 0) {
129✔
473
          delete metadata.positionData;
11✔
474
        }
475
      }
476
    }
477
  }
478

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

482
    metadata.positionData = metadata.positionData || {};
329✔
483
    metadata.positionData.country = Utils.asciiToUTF8(exif.iptc?.Country) || Utils.decodeHTMLChars(exif.photoshop?.Country);
329✔
484
    metadata.positionData.state = Utils.asciiToUTF8(exif.iptc?.State) || Utils.decodeHTMLChars(exif.photoshop?.State);
329✔
485
    metadata.positionData.city = Utils.asciiToUTF8(exif.iptc?.City) || Utils.decodeHTMLChars(exif.photoshop?.City);
329✔
486
    if (metadata.positionData) {
329✔
487
      Utils.removeNullOrEmptyObj(metadata.positionData);
329✔
488
      if (Object.keys(metadata.positionData).length === 0) {
329✔
489
        delete metadata.positionData;
190✔
490
      }
491
    }
492
  }
493

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

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

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

592
          if (type !== 'Face' || !name) {
202!
593
            continue;
×
594
          }
595

596
          // convert center base box to corner based box
597
          box.left = Math.round(Math.max(0, box.left - box.width / 2));
202✔
598
          box.top = Math.round(Math.max(0, box.top - box.height / 2));
202✔
599

600

601
          faces.push({name, box});
202✔
602
        }
603
      }
604
      if (faces.length > 0) {
114✔
605
        metadata.faces = faces; // save faces
114✔
606
        if (Config.Faces.keywordsToPersons) {
114✔
607
          // remove faces from keywords
608
          metadata.faces.forEach((f) => {
114✔
609
            const index = metadata.keywords.indexOf(f.name);
202✔
610
            if (index !== -1) {
202✔
611
              metadata.keywords.splice(index, 1);
180✔
612
            }
613
          });
614
        }
615
      }
616
    }
617
  }
618
}
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