exiftool-vendored 33.2.0 → 33.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Timezones.js CHANGED
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TimezoneOffsetTagnames = exports.defaultVideosToUTC = exports.UnsetZoneName = exports.UnsetZone = exports.UnsetZoneOffsetMinutes = exports.ValidTimezoneOffsets = exports.ArchaicTimezoneOffsets = void 0;
3
+ exports.TimezoneOffsetTagnames = exports.TimezoneOffsetRE = exports.defaultVideosToUTC = exports.UnsetZoneName = exports.UnsetZone = exports.UnsetZoneOffsetMinutes = exports.ValidTimezoneOffsets = exports.ArchaicTimezoneOffsets = void 0;
4
4
  exports.isUTC = isUTC;
5
5
  exports.isZoneUnset = isZoneUnset;
6
6
  exports.isZoneValid = isZoneValid;
7
7
  exports.isZone = isZone;
8
+ exports.parseTimezoneOffsetMatch = parseTimezoneOffsetMatch;
9
+ exports.parseTimezoneOffsetToMinutes = parseTimezoneOffsetToMinutes;
8
10
  exports.normalizeZone = normalizeZone;
9
11
  exports.zoneToShortOffset = zoneToShortOffset;
10
12
  exports.validTzOffsetMinutes = validTzOffsetMinutes;
@@ -110,14 +112,11 @@ exports.ValidTimezoneOffsets = [
110
112
  "+13:45", // New Zealand islands
111
113
  "+14:00",
112
114
  ];
113
- function offsetToMinutes(offset) {
114
- // Extract sign from string to handle "-00:25:21" correctly (negative zero)
115
- const sign = offset.startsWith("-") ? -1 : 1;
116
- const parts = offset.replace(/^[+-]/, "").split(":").map(Number);
117
- const [h, m = 0, s = 0] = parts;
118
- return sign * (h * 60 + m + s / 60);
119
- }
120
- const validOffsetMinutes = (0, Lazy_1.lazy)(() => {
115
+ /**
116
+ * Get all valid timezone offset minutes based on current settings.
117
+ * Used by both validOffsetMinutes (Set) and likelyOffsetMinutes (Array).
118
+ */
119
+ function getValidOffsetMinutes() {
121
120
  const offsets = [...exports.ValidTimezoneOffsets];
122
121
  if (Settings_1.Settings.allowArchaicTimezoneOffsets.value) {
123
122
  offsets.push(...exports.ArchaicTimezoneOffsets);
@@ -125,15 +124,18 @@ const validOffsetMinutes = (0, Lazy_1.lazy)(() => {
125
124
  if (Settings_1.Settings.allowBakerIslandTime.value) {
126
125
  offsets.push("-12:00");
127
126
  }
128
- return new Set(offsets.map(offsetToMinutes));
129
- });
130
- // Invalidate cache when relevant settings change
131
- Settings_1.Settings.allowArchaicTimezoneOffsets.onChange(() => {
132
- validOffsetMinutes.clear();
133
- });
134
- Settings_1.Settings.allowBakerIslandTime.onChange(() => {
127
+ return offsets.map(parseTimezoneOffsetToMinutes).filter(Number_1.isNumber);
128
+ }
129
+ const validOffsetMinutes = (0, Lazy_1.lazy)(() => new Set(getValidOffsetMinutes()));
130
+ // Used for fuzzy matching in inferLikelyOffsetMinutes
131
+ const likelyOffsetMinutes = (0, Lazy_1.lazy)(() => getValidOffsetMinutes());
132
+ // Invalidate both caches when relevant settings change
133
+ function clearOffsetCaches() {
135
134
  validOffsetMinutes.clear();
136
- });
135
+ likelyOffsetMinutes.clear();
136
+ }
137
+ Settings_1.Settings.allowArchaicTimezoneOffsets.onChange(clearOffsetCaches);
138
+ Settings_1.Settings.allowBakerIslandTime.onChange(clearOffsetCaches);
137
139
  /**
138
140
  * Zone instances with this offset are a placeholder for being "unset".
139
141
  */
@@ -151,16 +153,44 @@ const Zulus = [
151
153
  luxon_1.FixedOffsetZone.utcInstance,
152
154
  0,
153
155
  -0,
156
+ "0", // String zero - some systems output this for numeric offsets
154
157
  "UTC",
155
158
  "GMT",
156
159
  "Z",
160
+ "Etc/UTC", // Valid IANA timezone name for UTC
157
161
  "+0",
162
+ "+00",
163
+ "-00",
158
164
  "+00:00",
165
+ "-00:00",
159
166
  "UTC+0",
160
167
  "GMT+0",
161
168
  "UTC+00:00",
162
169
  "GMT+00:00",
163
170
  ];
171
+ /**
172
+ * Check if a timezone value represents UTC.
173
+ *
174
+ * Handles multiple UTC representations including Zone instances, strings, and
175
+ * numeric offsets. Recognizes common UTC aliases like "GMT", "Z", "Zulu",
176
+ * "+0", "+00:00", etc.
177
+ *
178
+ * @param zone - Timezone to check (Zone, string, or number)
179
+ * @returns true if the zone represents UTC/GMT/Zulu
180
+ *
181
+ * @example
182
+ * ```typescript
183
+ * isUTC("UTC") // true
184
+ * isUTC("GMT") // true
185
+ * isUTC("Z") // true
186
+ * isUTC("Zulu") // true
187
+ * isUTC(0) // true
188
+ * isUTC("+00:00") // true
189
+ * isUTC("UTC+0") // true
190
+ * isUTC("America/New_York") // false
191
+ * isUTC("+08:00") // false
192
+ * ```
193
+ */
164
194
  function isUTC(zone) {
165
195
  if (zone == null) {
166
196
  return false;
@@ -168,22 +198,105 @@ function isUTC(zone) {
168
198
  if (typeof zone === "string" || typeof zone === "number") {
169
199
  return Zulus.includes(zone);
170
200
  }
171
- if (zone instanceof luxon_1.Zone) {
201
+ if (isZone(zone)) {
172
202
  return zone.isUniversal && zone.offset(Date.now()) === 0;
173
203
  }
174
204
  return false;
175
205
  }
206
+ /**
207
+ * Check if a Zone is the library's sentinel "unset" value.
208
+ *
209
+ * The library uses a special Zone instance to represent unknown/unset
210
+ * timezones since Luxon doesn't officially support unset zones.
211
+ *
212
+ * @param zone - Zone instance to check
213
+ * @returns true if the zone is the UnsetZone sentinel value
214
+ *
215
+ * @see {@link UnsetZone}
216
+ * @see {@link UnsetZoneName}
217
+ * @see {@link UnsetZoneOffsetMinutes}
218
+ *
219
+ * @example
220
+ * ```typescript
221
+ * isZoneUnset(UnsetZone) // true
222
+ * isZoneUnset(Info.normalizeZone("UTC")) // false
223
+ * isZoneUnset(Info.normalizeZone("UTC+8")) // false
224
+ * ```
225
+ */
176
226
  function isZoneUnset(zone) {
177
- return zone.isUniversal && zone.offset(0) === exports.UnsetZoneOffsetMinutes;
227
+ return (isZone(zone) &&
228
+ zone.isUniversal &&
229
+ zone.offset(0) === exports.UnsetZoneOffsetMinutes);
178
230
  }
231
+ /**
232
+ * Type guard to check if a Zone is valid and usable.
233
+ *
234
+ * A zone is considered valid if it:
235
+ * - Is not null/undefined
236
+ * - Has `isValid === true` (Luxon requirement)
237
+ * - Is not the library's UnsetZone sentinel
238
+ * - Has an offset within ±14 hours (the valid range for real-world timezones)
239
+ *
240
+ * This is the canonical validation check used throughout the library.
241
+ *
242
+ * @param zone - Zone to validate
243
+ * @returns true if the zone is valid and usable (type guard)
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * const zone = Info.normalizeZone("America/Los_Angeles")
248
+ * if (isZoneValid(zone)) {
249
+ * // TypeScript knows zone is Zone (not Zone | undefined)
250
+ * console.log(zone.name)
251
+ * }
252
+ *
253
+ * isZoneValid(Info.normalizeZone("invalid")) // false
254
+ * isZoneValid(Info.normalizeZone("UTC+8")) // true
255
+ * isZoneValid(UnsetZone) // false
256
+ * isZoneValid(Info.normalizeZone("UTC+20")) // false (beyond ±14 hours)
257
+ * ```
258
+ */
179
259
  function isZoneValid(zone) {
180
- return (zone != null &&
181
- zone.isValid &&
182
- !isZoneUnset(zone) &&
183
- Math.abs(zone.offset(Date.now())) < 14 * 60);
260
+ // Note: isZone() already verifies zone.isValid
261
+ if (!isZone(zone) || isZoneUnset(zone)) {
262
+ return false;
263
+ }
264
+ // For fixed offset zones, verify the offset is in our valid set
265
+ if (zone.isUniversal) {
266
+ return validTzOffsetMinutes(zone.offset(Date.now()));
267
+ }
268
+ // For IANA zones, we trust that Luxon has validated them
269
+ return true;
184
270
  }
271
+ /**
272
+ * Type guard to check if a value is a Luxon Zone instance.
273
+ *
274
+ * Checks both `instanceof Zone` and constructor name to handle cross-module
275
+ * Zone instances that may not pass instanceof checks.
276
+ *
277
+ * @param zone - Value to check
278
+ * @returns true if the value is a Zone instance (type guard)
279
+ *
280
+ * @example
281
+ * ```typescript
282
+ * import { Info } from "luxon"
283
+ *
284
+ * const zone = Info.normalizeZone("UTC+8")
285
+ * if (isZone(zone)) {
286
+ * // TypeScript knows zone is Zone (not unknown)
287
+ * console.log(zone.offset(Date.now()))
288
+ * }
289
+ *
290
+ * isZone(Info.normalizeZone("UTC")) // true
291
+ * isZone("UTC") // false
292
+ * isZone(480) // false
293
+ * isZone(null) // false
294
+ * ```
295
+ */
185
296
  function isZone(zone) {
186
- return ((0, Object_1.isObject)(zone) && (zone instanceof luxon_1.Zone || zone.constructor.name === "Zone"));
297
+ return ((0, Object_1.isObject)(zone) &&
298
+ (zone instanceof luxon_1.Zone || zone.constructor.name === "Zone") &&
299
+ zone.isValid);
187
300
  }
188
301
  /**
189
302
  * If `tzSource` matches this value, the tags are from a video, and we had to
@@ -195,37 +308,184 @@ exports.defaultVideosToUTC = "defaultVideosToUTC";
195
308
  // "WET" and "W-SU" are full TZs (!!!), and "America/Indiana/Indianapolis" is
196
309
  // also a thing.
197
310
  const IanaFormatRE = /^\w{2,15}(?:\/\w{3,15}){0,2}$/;
198
- // Luxon requires fixed-offset zones to look like "UTC+H", "UTC-H",
199
- // "UTC+H:mm", "UTC-H:mm":
200
- const FixedFormatRE = /^UTC(?<sign>[+-])(?<hours>\d+)(?::(?<minutes>\d{2}))?$/;
201
- function parseFixedOffset(str) {
202
- const match = FixedFormatRE.exec(str)?.groups;
203
- if (match == null)
311
+ const OffsetStringRE = /^(?:UTC|GMT)?(?<sign>[+−-])(?<hours>\d{1,2})(?::(?<minutes>\d{2})(?::(?<seconds>\d{2}))?)?$/;
312
+ const MinusRE = /[−-]/;
313
+ /**
314
+ * Composable regex pattern for matching timezone offsets.
315
+ *
316
+ * Designed for embedding in larger patterns (no ^ or $ anchors).
317
+ * Matches UTC/GMT/Z or signed offsets like +08:00, -05:30.
318
+ *
319
+ * Named capture groups:
320
+ * - `tz_utc`: Matches "Z", "UTC", or "GMT"
321
+ * - `tz_sign`: The sign character (+, -, or Unicode minus −)
322
+ * - `tz_hours`: Hour offset (1-2 digits)
323
+ * - `tz_minutes`: Optional minute offset (2 digits)
324
+ *
325
+ * @example
326
+ * ```typescript
327
+ * // Concatenate with other patterns
328
+ * const dateTimeRE = new RegExp(
329
+ * `(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})${TimezoneOffsetRE.source}`
330
+ * )
331
+ *
332
+ * // Use standalone
333
+ * const match = TimezoneOffsetRE.exec("2023-01-15T10:30:00-08:00")
334
+ * if (match?.groups) {
335
+ * const { tz_sign, tz_hours, tz_minutes } = match.groups
336
+ * // tz_sign = "-", tz_hours = "08", tz_minutes = "00"
337
+ * }
338
+ * ```
339
+ */
340
+ exports.TimezoneOffsetRE = /(?:(?<tz_utc>Z|UTC|GMT)|(?<tz_sign>[+−-])(?<tz_hours>[01]?\d)(?::(?<tz_minutes>\d\d))?)/;
341
+ /**
342
+ * Parse timezone offset from a regex match result.
343
+ *
344
+ * Use with {@link TimezoneOffsetRE} to extract offset minutes from a match.
345
+ *
346
+ * @param match - RegExp exec result from TimezoneOffsetRE
347
+ * @returns Parsed offset info, or undefined if match is invalid
348
+ *
349
+ * @example
350
+ * ```typescript
351
+ * const match = TimezoneOffsetRE.exec("2023-01-15T10:30:00-08:00")
352
+ * const result = parseTimezoneOffsetMatch(match)
353
+ * // { offsetMinutes: -480, isUtc: false }
354
+ * ```
355
+ */
356
+ function parseTimezoneOffsetMatch(match) {
357
+ if (match?.groups == null)
204
358
  return;
205
- const h = (0, Number_1.toInt)(match.hours);
206
- const m = (0, Number_1.toInt)(match.minutes) ?? 0;
359
+ const { tz_utc, tz_sign, tz_hours, tz_minutes } = match.groups;
360
+ if (tz_utc != null) {
361
+ return { offsetMinutes: 0, isUtc: true };
362
+ }
363
+ if (tz_sign == null || tz_hours == null)
364
+ return;
365
+ const h = (0, Number_1.toInt)(tz_hours);
366
+ const m = (0, Number_1.toInt)(tz_minutes) ?? 0;
207
367
  if (h == null || h < 0 || h > 14 || m < 0 || m >= 60)
208
368
  return;
209
- const result = (match.sign === "-" ? -1 : 1) * (h * 60 + m);
210
- return (validOffsetMinutes().has(result) ? result : undefined) ?? undefined;
369
+ const signValue = MinusRE.test(tz_sign) ? -1 : 1;
370
+ return {
371
+ offsetMinutes: signValue * (h * 60 + m),
372
+ isUtc: false,
373
+ };
374
+ }
375
+ /**
376
+ * Parse a timezone offset string to offset minutes.
377
+ *
378
+ * Accepts multiple formats:
379
+ * - ISO 8601: "+08:00", "-05:30", "Z"
380
+ * - Luxon format: "UTC+8", "GMT-5"
381
+ * - UTC variants: "UTC", "GMT", "Zulu"
382
+ *
383
+ * Supports seconds for archaic offsets like "-00:25:21" (Ireland 1880-1916).
384
+ *
385
+ * **Note:** Does NOT validate that the offset is a real-world timezone offset.
386
+ * Use {@link validTzOffsetMinutes} for validation.
387
+ *
388
+ * @param str - Timezone offset string
389
+ * @returns Offset in minutes, or undefined if invalid
390
+ *
391
+ * @example
392
+ * ```typescript
393
+ * parseTimezoneOffsetToMinutes("+08:00") // 480
394
+ * parseTimezoneOffsetToMinutes("UTC-5") // -300
395
+ * parseTimezoneOffsetToMinutes("Z") // 0
396
+ * parseTimezoneOffsetToMinutes("-00:25:21") // -25.35 (archaic Ireland)
397
+ * parseTimezoneOffsetToMinutes("invalid") // undefined
398
+ * ```
399
+ */
400
+ function parseTimezoneOffsetToMinutes(str) {
401
+ if (isUTC(str))
402
+ return 0;
403
+ const match = OffsetStringRE.exec(str);
404
+ if (match?.groups == null)
405
+ return;
406
+ const { hours, minutes, seconds, sign } = match.groups;
407
+ if (hours == null || sign == null)
408
+ return;
409
+ const h = (0, Number_1.toInt)(hours);
410
+ const m = (0, Number_1.toInt)(minutes) ?? 0;
411
+ const s = (0, Number_1.toInt)(seconds) ?? 0;
412
+ if (h == null || h < 0 || h > 14 || m < 0 || m >= 60 || s < 0 || s >= 60)
413
+ return;
414
+ // Handle both ASCII minus (-) and Unicode minus (−, U+2212)
415
+ const signValue = MinusRE.test(sign) ? -1 : 1;
416
+ return signValue * (h * 60 + m + s / 60);
211
417
  }
212
418
  /**
213
- * @param input must be either a number, which is the offset in minutes, or a
214
- * string in the format "UTC+H" or "UTC+HH:mm"
419
+ * Normalize a timezone input to a valid Luxon Zone.
420
+ *
421
+ * Accepts multiple input formats and returns a validated Zone instance, or
422
+ * undefined if the input cannot be normalized to a valid timezone.
423
+ *
424
+ * Supported input formats:
425
+ * - **Numbers**: Timezone offset in minutes (e.g., 480 = UTC+8, -300 = UTC-5)
426
+ * - **Strings**: ISO offsets ("+08:00", "-05:00"), IANA zones
427
+ * ("America/Los_Angeles"), UTC variants ("UTC", "GMT", "Z", "Zulu")
428
+ * - **Zone instances**: Validated and returned if valid
429
+ *
430
+ * The function respects Settings:
431
+ * - {@link Settings.allowArchaicTimezoneOffsets} for pre-1982 offsets
432
+ * - {@link Settings.allowBakerIslandTime} for UTC-12:00
433
+ *
434
+ * @param input - Timezone in various formats
435
+ * @returns Valid Zone instance, or undefined if invalid
436
+ *
437
+ * @example
438
+ * ```typescript
439
+ * // Numbers (offset in minutes)
440
+ * normalizeZone(480)?.name // "UTC+8"
441
+ * normalizeZone(-300)?.name // "UTC-5"
442
+ * normalizeZone(0)?.name // "UTC"
443
+ *
444
+ * // ISO offset strings
445
+ * normalizeZone("+08:00")?.name // "UTC+8"
446
+ * normalizeZone("-05:30")?.name // "UTC-5:30"
447
+ * normalizeZone("UTC+7")?.name // "UTC+7"
448
+ *
449
+ * // IANA timezone names
450
+ * normalizeZone("America/Los_Angeles")?.name // "America/Los_Angeles"
451
+ * normalizeZone("Asia/Tokyo")?.name // "Asia/Tokyo"
452
+ *
453
+ * // UTC aliases
454
+ * normalizeZone("UTC")?.name // "UTC"
455
+ * normalizeZone("GMT")?.name // "UTC"
456
+ * normalizeZone("Z")?.name // "UTC"
457
+ * normalizeZone("Zulu")?.name // "UTC"
458
+ *
459
+ * // Invalid inputs return undefined
460
+ * normalizeZone("invalid") // undefined
461
+ * normalizeZone("+25:00") // undefined (beyond ±14 hours)
462
+ * normalizeZone(1200) // undefined (20 hours, beyond ±14 hours)
463
+ * normalizeZone(100) // undefined (not a valid timezone offset)
464
+ * normalizeZone(-1) // undefined (UnsetZone sentinel)
465
+ * normalizeZone(null) // undefined
466
+ * ```
215
467
  */
216
468
  function normalizeZone(input) {
217
469
  if (input == null ||
218
470
  (0, String_1.blank)(input) ||
219
- (!(0, Number_1.isNumber)(input) && !(0, String_1.isString)(input) && !isZone(input))) {
471
+ (!(0, Number_1.isNumber)(input) && !(0, String_1.isString)(input) && !isZone(input)) ||
472
+ input === exports.UnsetZone ||
473
+ input === exports.UnsetZoneOffsetMinutes ||
474
+ isZoneUnset(input)) {
220
475
  return;
221
476
  }
222
477
  // wrapped in a try/catch as Luxon.settings.throwOnInvalid may be true:
223
478
  try {
224
479
  // This test and short-circuit may not be necessary, but it's cheap and
225
480
  // explicit:
226
- if (isUTC(input))
481
+ if (isUTC(input)) {
227
482
  return luxon_1.FixedOffsetZone.utcInstance;
483
+ }
484
+ // TypeScript-friendly: after line 386's type check, we know input is one of these
228
485
  let z = input;
486
+ if ((0, Number_1.isNumber)(z) && !validTzOffsetMinutes(z)) {
487
+ return;
488
+ }
229
489
  if ((0, String_1.isString)(z)) {
230
490
  let s = z;
231
491
  z = s = s.replace(/^(?:Zulu|Z|GMT)(?:\b|$)/i, "UTC");
@@ -233,8 +493,8 @@ function normalizeZone(input) {
233
493
  // non-offset inputs:
234
494
  if ((0, String_1.blank)(s))
235
495
  return;
236
- const fixed = parseFixedOffset(s);
237
- if (fixed != null) {
496
+ const fixed = parseTimezoneOffsetToMinutes(s);
497
+ if (fixed != null && validTzOffsetMinutes(fixed)) {
238
498
  return luxon_1.Info.normalizeZone(fixed);
239
499
  }
240
500
  if (!IanaFormatRE.test(s)) {
@@ -251,13 +511,73 @@ function normalizeZone(input) {
251
511
  }
252
512
  }
253
513
  /**
254
- * @param ts must be provided if the zone is not a fixed offset
255
- * @return the zone offset (in "±HH:MM" format) for the given zone, or "" if
256
- * the zone is invalid
514
+ * Convert a timezone to its short offset format (e.g., "+08:00", "-05:00").
515
+ *
516
+ * Useful for displaying timezone offsets in a standardized format. For IANA
517
+ * zones with daylight saving time, provide a timestamp to get the correct
518
+ * offset for that moment.
519
+ *
520
+ * @param zone - Timezone as Zone, string, or offset in minutes
521
+ * @param ts - Optional timestamp (milliseconds) for IANA zone offset calculation.
522
+ * Defaults to current time if not provided.
523
+ * @returns Zone offset in "+HH:MM" format, or "" if zone is invalid
524
+ *
525
+ * @example
526
+ * ```typescript
527
+ * // Fixed offsets
528
+ * zoneToShortOffset("UTC+8") // "+08:00"
529
+ * zoneToShortOffset(480) // "+08:00"
530
+ * zoneToShortOffset("UTC-5:30") // "-05:30"
531
+ *
532
+ * // IANA zones (offset depends on DST)
533
+ * const winter = new Date("2023-01-15").getTime()
534
+ * const summer = new Date("2023-07-15").getTime()
535
+ * zoneToShortOffset("America/Los_Angeles", winter) // "-08:00" (PST)
536
+ * zoneToShortOffset("America/Los_Angeles", summer) // "-07:00" (PDT)
537
+ *
538
+ * // Invalid zones return empty string
539
+ * zoneToShortOffset("invalid") // ""
540
+ * zoneToShortOffset(null) // ""
541
+ * ```
257
542
  */
258
543
  function zoneToShortOffset(zone, ts) {
259
544
  return normalizeZone(zone)?.formatOffset(ts ?? Date.now(), "short") ?? "";
260
545
  }
546
+ /**
547
+ * Type guard to check if a numeric offset (in minutes) represents a valid timezone.
548
+ *
549
+ * Validates that the offset:
550
+ * - Is a number (not null/undefined)
551
+ * - Is not the UnsetZone sentinel value (-1)
552
+ * - Matches a real-world timezone offset (respects Settings for archaic offsets)
553
+ *
554
+ * Use this for exact validation without rounding. For error-tolerant rounding to
555
+ * the nearest valid offset, use {@link inferLikelyOffsetMinutes} instead.
556
+ *
557
+ * @param tzOffsetMinutes - Offset in minutes to validate (e.g., 480 for UTC+8)
558
+ * @returns true if the offset is exactly valid (type guard)
559
+ *
560
+ * @see {@link inferLikelyOffsetMinutes} for error-tolerant rounding
561
+ *
562
+ * @example
563
+ * ```typescript
564
+ * validTzOffsetMinutes(480) // true (UTC+8)
565
+ * validTzOffsetMinutes(-300) // true (UTC-5)
566
+ * validTzOffsetMinutes(330) // true (UTC+5:30, India)
567
+ * validTzOffsetMinutes(345) // true (UTC+5:45, Nepal)
568
+ *
569
+ * validTzOffsetMinutes(481) // false (not a valid timezone)
570
+ * validTzOffsetMinutes(-1) // false (UnsetZone sentinel)
571
+ * validTzOffsetMinutes(null) // false
572
+ *
573
+ * // Archaic offsets require Settings
574
+ * Settings.allowArchaicTimezoneOffsets.value = false
575
+ * validTzOffsetMinutes(-630) // false (Hawaii -10:30, archaic)
576
+ *
577
+ * Settings.allowArchaicTimezoneOffsets.value = true
578
+ * validTzOffsetMinutes(-630) // true (Hawaii -10:30, archaic)
579
+ * ```
580
+ */
261
581
  function validTzOffsetMinutes(tzOffsetMinutes) {
262
582
  return (tzOffsetMinutes != null &&
263
583
  (0, Number_1.isNumber)(tzOffsetMinutes) &&
@@ -287,8 +607,9 @@ function tzHourToOffset(n) {
287
607
  }
288
608
  // Accept "Z", "UTC+2", "UTC+02", "UTC+2:00", "UTC+02:00", "+2", "+02", and
289
609
  // "+02:00". Also accepts seconds like "-00:25:21" for archaic offsets.
610
+ // Handles Unicode minus (−, U+2212)
290
611
  // Require the sign (+ or -) and a ":" separator if there are minutes.
291
- const tzRe = /(?<Z>Z)|((UTC)?(?<sign>[+-])(?<hours>\d\d?)(?::(?<minutes>\d\d)(?::(?<seconds>\d\d))?)?)$/;
612
+ const tzRe = /(?<Z>Z)|((UTC)?(?<sign>[+−-])(?<hours>\d\d?)(?::(?<minutes>\d\d)(?::(?<seconds>\d\d))?)?)$/;
292
613
  function extractOffsetFromHours(hourOffset) {
293
614
  return (0, Number_1.isNumber)(hourOffset)
294
615
  ? (0, Maybe_1.map)(tzHourToOffset(hourOffset), (zone) => ({
@@ -301,12 +622,66 @@ function extractOffsetFromHours(hourOffset) {
301
622
  : undefined;
302
623
  }
303
624
  /**
304
- * Parse a timezone offset and return the offset minutes
625
+ * Extract timezone information from various value types.
626
+ *
627
+ * Handles multiple input formats and performs intelligent parsing:
628
+ * - **Strings**: ISO offsets ("+08:00"), IANA zones, UTC variants, timestamps
629
+ * with embedded timezones ("2023:01:15 10:30:00-08:00")
630
+ * - **Numbers**: Hour offsets (e.g., -8 for UTC-8)
631
+ * - **Arrays**: Uses first non-null value
632
+ * - **ExifDateTime/ExifTime instances**: Extracts their zone property
633
+ *
634
+ * By default, strips timezone abbreviations (PST, PDT, etc.) as they are
635
+ * ambiguous. Returns provenance information indicating which parsing method
636
+ * succeeded.
637
+ *
638
+ * Supports Unicode minus signs (−, U+2212) and plus-minus signs (±, U+00B1)
639
+ * in addition to ASCII +/-.
640
+ *
641
+ * @param value - Value to extract timezone from
642
+ * @param opts.stripTZA - Whether to strip timezone abbreviations (default: true).
643
+ * TZAs like "PST" are ambiguous and usually stripped.
644
+ * @returns TzSrc with zone name and provenance, or undefined if no timezone found
645
+ *
646
+ * @example
647
+ * ```typescript
648
+ * // ISO offset strings
649
+ * extractZone("+08:00")
650
+ * // { zone: "UTC+8", tz: "UTC+8", src: "offsetMinutesToZoneName" }
651
+ *
652
+ * extractZone("UTC-5:30")
653
+ * // { zone: "UTC-5:30", tz: "UTC-5:30", src: "normalizeZone" }
654
+ *
655
+ * // IANA zone names
656
+ * extractZone("America/Los_Angeles")
657
+ * // { zone: "America/Los_Angeles", tz: "America/Los_Angeles", src: "normalizeZone" }
658
+ *
659
+ * // Timestamps with embedded timezones
660
+ * extractZone("2023:01:15 10:30:00-08:00")
661
+ * // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName",
662
+ * // leftovers: "2023:01:15 10:30:00" }
663
+ *
664
+ * // Unicode minus signs
665
+ * extractZone("−08:00") // Unicode minus (U+2212)
666
+ * // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName" }
667
+ *
668
+ * // Numeric hour offsets
669
+ * extractZone(-8)
670
+ * // { zone: "UTC-8", tz: "UTC-8", src: "hourOffset" }
305
671
  *
306
- * @param opts.stripTZA If false, do not strip off the timezone abbreviation
307
- * (TZA) from the value. Defaults to true.
672
+ * // Arrays (uses first non-null)
673
+ * extractZone([null, "+05:30", undefined])
674
+ * // { zone: "UTC+5:30", tz: "UTC+5:30", src: "offsetMinutesToZoneName" }
308
675
  *
309
- * @return undefined if the value cannot be parsed as a valid timezone offset
676
+ * // Strips timezone abbreviations by default
677
+ * extractZone("2023:01:15 10:30:00-08:00 PST")
678
+ * // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName",
679
+ * // leftovers: "2023:01:15 10:30:00" }
680
+ *
681
+ * // Invalid inputs return undefined
682
+ * extractZone("invalid") // undefined
683
+ * extractZone(null) // undefined
684
+ * ```
310
685
  */
311
686
  function extractZone(value, opts) {
312
687
  if (value == null ||
@@ -375,7 +750,8 @@ function extractZone(value, opts) {
375
750
  const hours = parseInt(capturedGroups.hours ?? "0");
376
751
  const minutes = parseInt(capturedGroups.minutes ?? "0");
377
752
  const seconds = parseInt(capturedGroups.seconds ?? "0");
378
- const sign = capturedGroups.sign === "-" ? -1 : 1;
753
+ // Handle both ASCII minus (-) and Unicode minus (−, U+2212)
754
+ const sign = MinusRE.test(capturedGroups.sign ?? "") ? -1 : 1;
379
755
  const offsetMinutes = sign * (hours * 60 + minutes + seconds / 60);
380
756
  const zone = offsetMinutesToZoneName(offsetMinutes);
381
757
  if (zone != null) {
@@ -417,6 +793,47 @@ function incrementZone(z, minutes) {
417
793
  ? luxon_1.FixedOffsetZone.instance(fixed + minutes)
418
794
  : undefined;
419
795
  }
796
+ /**
797
+ * Extract timezone offset from standard EXIF timezone tags.
798
+ *
799
+ * Checks timezone tags in priority order:
800
+ * 1. TimeZone
801
+ * 2. OffsetTimeOriginal (for DateTimeOriginal)
802
+ * 3. OffsetTimeDigitized (for CreateDate)
803
+ * 4. TimeZoneOffset
804
+ *
805
+ * Handles camera-specific quirks like Nikon's DaylightSavings tag, which
806
+ * requires adjusting the TimeZone offset forward by one hour during DST.
807
+ *
808
+ * @param t - EXIF tags object
809
+ * @param opts.adjustTimeZoneIfDaylightSavings - Optional function to adjust
810
+ * timezone for DST. Defaults to handling Nikon's DaylightSavings quirk.
811
+ * @returns TzSrc with zone and provenance, or undefined if no timezone found
812
+ *
813
+ * @see {@link TimezoneOffsetTagnames} for the list of tags checked
814
+ * @see https://github.com/photostructure/exiftool-vendored.js/issues/215
815
+ *
816
+ * @example
817
+ * ```typescript
818
+ * const tags = await exiftool.read("photo.jpg")
819
+ *
820
+ * const tzSrc = extractTzOffsetFromTags(tags)
821
+ * if (tzSrc) {
822
+ * console.log(`Timezone: ${tzSrc.zone}`)
823
+ * console.log(`Source: ${tzSrc.src}`) // e.g., "OffsetTimeOriginal"
824
+ * }
825
+ *
826
+ * // Nikon DST handling
827
+ * const nikonTags = {
828
+ * TimeZone: "-08:00",
829
+ * DaylightSavings: "Yes",
830
+ * Make: "NIKON CORPORATION"
831
+ * }
832
+ * extractTzOffsetFromTags(nikonTags)
833
+ * // { zone: "UTC-7", tz: "UTC-7",
834
+ * // src: "TimeZone (adjusted for DaylightSavings)" }
835
+ * ```
836
+ */
420
837
  function extractTzOffsetFromTags(t, opts) {
421
838
  const adjustFn = opts?.adjustTimeZoneIfDaylightSavings ??
422
839
  DefaultExifToolOptions_1.defaultAdjustTimeZoneIfDaylightSavings;
@@ -485,33 +902,69 @@ function extractTzOffsetFromTimeStamp(t, opts) {
485
902
  }
486
903
  return;
487
904
  }
488
- // timezone offsets may be on a 15 minute boundary, but if GPS acquisition is
489
- // old, this can be spurious. We get less mistakes with a larger multiple, so
490
- // we're using 30 minutes instead of 15. See
491
- // https://www.timeanddate.com/time/time-zones-interesting.html
492
- const likelyOffsetMinutes = (0, Lazy_1.lazy)(() => {
493
- const offsets = [...exports.ValidTimezoneOffsets];
494
- if (Settings_1.Settings.allowArchaicTimezoneOffsets.value) {
495
- offsets.push(...exports.ArchaicTimezoneOffsets);
496
- }
497
- if (Settings_1.Settings.allowBakerIslandTime.value) {
498
- offsets.push("-12:00");
499
- }
500
- return offsets.map(offsetToMinutes);
501
- });
502
- // Invalidate cache when relevant settings change
503
- Settings_1.Settings.allowArchaicTimezoneOffsets.onChange(() => {
504
- likelyOffsetMinutes.clear();
505
- });
506
- Settings_1.Settings.allowBakerIslandTime.onChange(() => {
507
- likelyOffsetMinutes.clear();
508
- });
509
- function inferLikelyOffsetMinutes(deltaMinutes) {
510
- const nearest = (0, Array_1.leastBy)(likelyOffsetMinutes(), (ea) => Math.abs(ea - deltaMinutes));
511
- // Reject timezone offsets more than 30 minutes away from the nearest:
512
- return nearest != null && Math.abs(nearest - deltaMinutes) < 30
513
- ? nearest
514
- : undefined;
905
+ /**
906
+ * Round an arbitrary offset to the nearest valid timezone offset.
907
+ *
908
+ * This is error-tolerant timezone inference, useful for:
909
+ * - GPS-based timezone calculation (where GPS time drift may cause errors)
910
+ * - Handling clock drift in timestamp comparisons
911
+ * - Fuzzy timezone matching
912
+ *
913
+ * By default, uses {@link Settings.maxValidOffsetMinutes} (30 minutes) as the
914
+ * maximum distance from a valid timezone. This threshold handles GPS acquisition
915
+ * lag and clock drift while preventing false matches.
916
+ *
917
+ * Respects Settings for archaic offsets, Baker Island time, and max offset tolerance.
918
+ *
919
+ * @param deltaMinutes - Offset in minutes to round (can be fractional)
920
+ * @param maxValidOffsetMinutes - Maximum distance (in minutes) from a valid
921
+ * timezone to accept. Defaults to {@link Settings.maxValidOffsetMinutes}.
922
+ * @returns Nearest valid offset in minutes, or undefined if too far from any
923
+ * valid timezone
924
+ *
925
+ * @see {@link validTzOffsetMinutes} for exact validation without rounding
926
+ * @see {@link Settings.maxValidOffsetMinutes} to configure the default threshold
927
+ *
928
+ * @example
929
+ * ```typescript
930
+ * // Exact matches
931
+ * inferLikelyOffsetMinutes(480) // 480 (UTC+8, exact)
932
+ * inferLikelyOffsetMinutes(-300) // -300 (UTC-5, exact)
933
+ *
934
+ * // Rounding within default threshold (30 minutes)
935
+ * inferLikelyOffsetMinutes(485) // 480 (UTC+8, rounded from 485)
936
+ * inferLikelyOffsetMinutes(-295) // -300 (UTC-5, rounded from -295)
937
+ * inferLikelyOffsetMinutes(330.5) // 330 (UTC+5:30, rounded)
938
+ *
939
+ * // GPS-based inference with clock drift (within 30 min default)
940
+ * const gpsTime = "2023:01:15 19:30:45" // UTC
941
+ * const localTime = "2023:01:15 11:32:12" // Local with 1.5min drift
942
+ * const deltaMinutes = 480 + 1.5 // ~481.5 minutes
943
+ * inferLikelyOffsetMinutes(deltaMinutes) // 480 (UTC+8)
944
+ *
945
+ * // GPS lag up to 23 minutes still works (within 30 min threshold)
946
+ * inferLikelyOffsetMinutes(443) // 420 (UTC-7, ~23 min from actual)
947
+ *
948
+ * // Beyond threshold returns undefined
949
+ * inferLikelyOffsetMinutes(100) // undefined (not near any valid offset)
950
+ *
951
+ * // Custom threshold
952
+ * inferLikelyOffsetMinutes(495, 30) // 480 (UTC+8 with 30min threshold)
953
+ * inferLikelyOffsetMinutes(495, 15) // undefined (beyond 15min threshold)
954
+ *
955
+ * // Adjust global default
956
+ * Settings.maxValidOffsetMinutes.value = 15 // Stricter matching
957
+ * inferLikelyOffsetMinutes(443) // undefined (beyond 15min threshold)
958
+ * ```
959
+ */
960
+ function inferLikelyOffsetMinutes(deltaMinutes, maxValidOffsetMinutes = Settings_1.Settings.maxValidOffsetMinutes.value) {
961
+ return deltaMinutes == null
962
+ ? undefined
963
+ : (0, Array_1.leastBy)(likelyOffsetMinutes(), (ea) => {
964
+ const diff = Math.abs(ea - deltaMinutes);
965
+ // Reject timezone offsets more than maxValidOffsetMinutes minutes away:
966
+ return diff > maxValidOffsetMinutes ? undefined : diff;
967
+ });
515
968
  }
516
969
  /**
517
970
  * Convert blank strings to undefined.
@@ -519,6 +972,62 @@ function inferLikelyOffsetMinutes(deltaMinutes) {
519
972
  function toNotBlank(x) {
520
973
  return x == null || (typeof x === "string" && (0, String_1.blank)(x)) ? undefined : x;
521
974
  }
975
+ /**
976
+ * Infer timezone offset by comparing local time with GPS/UTC time.
977
+ *
978
+ * Calculates the timezone by finding the difference between:
979
+ * - A "captured at" timestamp (DateTimeOriginal, CreateDate, etc.) assumed to
980
+ * be in local time
981
+ * - A UTC timestamp (GPSDateTime, DateTimeUTC, or combined GPSDateStamp +
982
+ * GPSTimeStamp)
983
+ *
984
+ * Uses {@link inferLikelyOffsetMinutes} to handle minor clock drift and round
985
+ * to the nearest valid timezone offset.
986
+ *
987
+ * This is a fallback when explicit timezone tags are not available.
988
+ *
989
+ * @param t - Tags object with timestamp fields
990
+ * @returns TzSrc with inferred timezone and provenance, or undefined if
991
+ * inference is not possible
992
+ *
993
+ * @see {@link extractTzOffsetFromTags} to check explicit timezone tags first
994
+ *
995
+ * @example
996
+ * ```typescript
997
+ * // GPS-based inference
998
+ * const tags = {
999
+ * DateTimeOriginal: "2023:01:15 11:30:00", // Local time (PST)
1000
+ * GPSDateTime: "2023:01:15 19:30:00" // UTC
1001
+ * }
1002
+ * extractTzOffsetFromUTCOffset(tags)
1003
+ * // { zone: "UTC-8", tz: "UTC-8",
1004
+ * // src: "offset between DateTimeOriginal and GPSDateTime" }
1005
+ *
1006
+ * // DateTimeUTC-based inference
1007
+ * const tags2 = {
1008
+ * CreateDate: "2023:07:20 14:15:30", // Local time (JST)
1009
+ * DateTimeUTC: "2023:07:20 05:15:30" // UTC
1010
+ * }
1011
+ * extractTzOffsetFromUTCOffset(tags2)
1012
+ * // { zone: "UTC+9", tz: "UTC+9",
1013
+ * // src: "offset between CreateDate and DateTimeUTC" }
1014
+ *
1015
+ * // Handles clock drift
1016
+ * const tags3 = {
1017
+ * DateTimeOriginal: "2023:01:15 11:30:45", // Local with drift
1018
+ * GPSDateTime: "2023:01:15 19:29:58" // UTC (old GPS fix)
1019
+ * }
1020
+ * extractTzOffsetFromUTCOffset(tags3)
1021
+ * // Still infers UTC-8 despite ~1 minute drift
1022
+ *
1023
+ * // No UTC timestamp available
1024
+ * const tags4 = {
1025
+ * DateTimeOriginal: "2023:01:15 11:30:00"
1026
+ * // No GPS or UTC timestamp
1027
+ * }
1028
+ * extractTzOffsetFromUTCOffset(tags4) // undefined
1029
+ * ```
1030
+ */
522
1031
  function extractTzOffsetFromUTCOffset(t) {
523
1032
  const utcSources = {
524
1033
  ...(0, Pick_1.pick)(t, "GPSDateTime", "DateTimeUTC", "SonyDateTime2"),
@@ -571,12 +1080,66 @@ function extractTzOffsetFromUTCOffset(t) {
571
1080
  src: `offset between ${dt.tagName} and ${utc.tagName}`,
572
1081
  }));
573
1082
  }
574
- function equivalentZones(a, b) {
1083
+ /**
1084
+ * Check if two timezone values are equivalent at a specific point in time.
1085
+ *
1086
+ * Two zones are considered equivalent if they:
1087
+ * - Are the same zone (via Luxon's Zone.equals()), OR
1088
+ * - Have the same offset at the specified timestamp
1089
+ *
1090
+ * This is useful for:
1091
+ * - De-duplicating timezone records
1092
+ * - Comparing zones in different formats ("UTC+5" vs "UTC+05:00")
1093
+ * - Matching IANA zones to their offset at a specific time
1094
+ *
1095
+ * For IANA zones with DST, you can specify a timestamp to evaluate equivalence
1096
+ * at that moment. This is important when comparing historical records or future
1097
+ * events where DST transitions matter.
1098
+ *
1099
+ * @param a - First timezone (Zone, string, or offset in minutes)
1100
+ * @param b - Second timezone (Zone, string, or offset in minutes)
1101
+ * @param at - Timestamp in milliseconds to evaluate zone offsets.
1102
+ * Defaults to current time (Date.now()).
1103
+ * @returns true if zones are equivalent at the specified time
1104
+ *
1105
+ * @example
1106
+ * ```typescript
1107
+ * // Same zone, different formats
1108
+ * equivalentZones("UTC+5", "UTC+05:00") // true
1109
+ * equivalentZones("UTC-8", -480) // true (480 minutes = 8 hours)
1110
+ * equivalentZones("GMT", "UTC") // true
1111
+ * equivalentZones("Z", 0) // true
1112
+ *
1113
+ * // IANA zones matched by current offset (default behavior)
1114
+ * equivalentZones("America/New_York", "UTC-5") // true in winter (EST)
1115
+ * equivalentZones("America/New_York", "UTC-4") // true in summer (EDT)
1116
+ *
1117
+ * // IANA zones at specific times
1118
+ * const winter = new Date("2023-01-15").getTime()
1119
+ * const summer = new Date("2023-07-15").getTime()
1120
+ * equivalentZones("America/New_York", "UTC-5", winter) // true (EST)
1121
+ * equivalentZones("America/New_York", "UTC-4", winter) // false (not EDT in winter)
1122
+ * equivalentZones("America/New_York", "UTC-4", summer) // true (EDT)
1123
+ * equivalentZones("America/New_York", "UTC-5", summer) // false (not EST in summer)
1124
+ *
1125
+ * // Compare two IANA zones at a specific time
1126
+ * equivalentZones("America/New_York", "America/Toronto", winter) // true (both EST)
1127
+ * equivalentZones("America/New_York", "America/Los_Angeles", winter) // false (EST vs PST)
1128
+ *
1129
+ * // Different zones
1130
+ * equivalentZones("UTC+8", "UTC+9") // false
1131
+ *
1132
+ * // Invalid zones return false
1133
+ * equivalentZones("invalid", "UTC") // false
1134
+ * equivalentZones(null, "UTC") // false
1135
+ * ```
1136
+ */
1137
+ function equivalentZones(a, b, at = Date.now()) {
575
1138
  const az = normalizeZone(a);
576
1139
  const bz = normalizeZone(b);
577
1140
  return (az != null &&
578
1141
  bz != null &&
579
- (az.equals(bz) || az.offset(Date.now()) === bz.offset(Date.now())));
1142
+ (az.equals(bz) || az.offset(at) === bz.offset(at)));
580
1143
  }
581
1144
  function getZoneName(args = {}) {
582
1145
  const result = normalizeZone(args.zone)?.name ??