exiftool-vendored
Version:
Efficient, cross-platform access to ExifTool
1,187 lines • 44.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.TimezoneOffsetTagnames = exports.TimezoneOffsetRE = exports.defaultVideosToUTC = exports.UnsetZoneName = exports.UnsetZone = exports.UnsetZoneOffsetMinutes = exports.ValidTimezoneOffsets = exports.ArchaicTimezoneOffsets = void 0;
exports.isUTC = isUTC;
exports.isZoneUnset = isZoneUnset;
exports.isZoneValid = isZoneValid;
exports.isZone = isZone;
exports.parseTimezoneOffsetMatch = parseTimezoneOffsetMatch;
exports.parseTimezoneOffsetToMinutes = parseTimezoneOffsetToMinutes;
exports.normalizeZone = normalizeZone;
exports.zoneToShortOffset = zoneToShortOffset;
exports.validTzOffsetMinutes = validTzOffsetMinutes;
exports.offsetMinutesToZoneName = offsetMinutesToZoneName;
exports.extractZone = extractZone;
exports.incrementZone = incrementZone;
exports.extractTzOffsetFromTags = extractTzOffsetFromTags;
exports.extractTzOffsetFromDatestamps = extractTzOffsetFromDatestamps;
exports.extractTzOffsetFromTimeStamp = extractTzOffsetFromTimeStamp;
exports.inferLikelyOffsetMinutes = inferLikelyOffsetMinutes;
exports.extractTzOffsetFromUTCOffset = extractTzOffsetFromUTCOffset;
exports.equivalentZones = equivalentZones;
exports.getZoneName = getZoneName;
const luxon_1 = require("luxon");
const Array_1 = require("./Array");
const BinaryField_1 = require("./BinaryField");
const CapturedAtTagNames_1 = require("./CapturedAtTagNames");
const DefaultExifToolOptions_1 = require("./DefaultExifToolOptions");
const ExifDate_1 = require("./ExifDate");
const ExifDateTime_1 = require("./ExifDateTime");
const ExifTime_1 = require("./ExifTime");
const Lazy_1 = require("./Lazy");
const Maybe_1 = require("./Maybe");
const Number_1 = require("./Number");
const Object_1 = require("./Object");
const Pick_1 = require("./Pick");
const Settings_1 = require("./Settings");
const String_1 = require("./String");
// When should we use "tz" vs "zone"?
// 1. "tz" refers to the entire database/system - the "tz database" (also called
// tzdata or zoneinfo)
// 2. "zone" refers to a specific time zone entry within that database
// So... we really should be using "zone" everywhere, not "tz".
// Unique values from
// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones, excluding those
// that have not been used for at least 50 years.
/**
* Archaic timezone offsets that have not been in use since 1982 or earlier
*/
exports.ArchaicTimezoneOffsets = [
"-10:30", // used by Hawaii 1896-1947
"-04:30", // used by Venezuela 1912-1965 and 2007-2016
"-00:44", // used by Liberia until 1972
"-00:25:21", // Ireland 1880-1916 https://en.wikipedia.org/wiki/UTC%E2%88%9200:25:21
"+00:20", // used by Netherlands until 1940
"+00:30", // used by Switzerland until 1936
"+01:24", // used by Warsaw until 1915
"+01:30", // used by some southern African countries until 1903
"+02:30", // archaic Moscow time
"+04:51", // used by Bombay until 1955 https://en.wikipedia.org/wiki/UTC%2B04:51
"+05:40", // used by Nepal until 1920
"+07:20", // used by Singapore and Malaysia until 1941
"+07:30", // used by Malaysia until 1982
];
/**
* Valid timezone offsets that are currently in use, or used after 1982
*/
exports.ValidTimezoneOffsets = [
// The UTC-12:00 timezone offset is used for uninhabited U.S. territories:
// - Baker Island (uninhabited)
// - Howland Island (uninhabited)
// "-12:00", // not used for any populated land!
"-11:00",
"-10:00",
"-09:30",
"-09:00",
"-08:30",
"-08:00",
"-07:00",
"-06:00",
"-05:00",
"-04:00",
"-03:30",
"-03:00",
"-02:30",
"-02:00",
"-01:00",
"+00:00",
"+01:00",
"+02:00",
"+03:00",
"+03:30",
"+04:00",
"+04:30",
"+05:00",
"+05:30",
"+05:45", // Nepal
"+06:00",
"+06:30",
"+07:00",
"+08:00",
"+08:30", // used by North Korea until 2018
"+08:45", // used by Western Australia, but not in tz database
"+09:00",
"+09:30",
"+09:45", // used by Western Australia, but not in tz database
"+10:00",
"+10:30",
"+11:00",
"+12:00",
"+12:45", // New Zealand islands
"+13:00", // New Zealand and Antarctica
"+13:45", // New Zealand islands
"+14:00",
];
/**
* Get all valid timezone offset minutes based on current settings.
* Used by both validOffsetMinutes (Set) and likelyOffsetMinutes (Array).
*/
function getValidOffsetMinutes() {
const offsets = [...exports.ValidTimezoneOffsets];
if (Settings_1.Settings.allowArchaicTimezoneOffsets.value) {
offsets.push(...exports.ArchaicTimezoneOffsets);
}
if (Settings_1.Settings.allowBakerIslandTime.value) {
offsets.push("-12:00");
}
return offsets.map(parseTimezoneOffsetToMinutes).filter(Number_1.isNumber);
}
const validOffsetMinutes = (0, Lazy_1.lazy)(() => new Set(getValidOffsetMinutes()));
// Used for fuzzy matching in inferLikelyOffsetMinutes
const likelyOffsetMinutes = (0, Lazy_1.lazy)(() => getValidOffsetMinutes());
// Invalidate both caches when relevant settings change
function clearOffsetCaches() {
validOffsetMinutes.clear();
likelyOffsetMinutes.clear();
}
Settings_1.Settings.allowArchaicTimezoneOffsets.onChange(clearOffsetCaches);
Settings_1.Settings.allowBakerIslandTime.onChange(clearOffsetCaches);
/**
* Zone instances with this offset are a placeholder for being "unset".
*/
exports.UnsetZoneOffsetMinutes = -1;
/**
* This is a placeholder for dates where the zone is unknown/unset, because
* Luxon doesn't officially support "unset" zones.
*/
exports.UnsetZone = luxon_1.Info.normalizeZone(exports.UnsetZoneOffsetMinutes);
/**
* Zone instances with this name are a placeholder for being "unset".
*/
exports.UnsetZoneName = exports.UnsetZone.name;
const Zulus = [
luxon_1.FixedOffsetZone.utcInstance,
0,
-0,
"0", // String zero - some systems output this for numeric offsets
"UTC",
"GMT",
"Z",
"Etc/UTC", // Valid IANA timezone name for UTC
"+0",
"+00",
"-00",
"+00:00",
"-00:00",
"UTC+0",
"GMT+0",
"UTC+00:00",
"GMT+00:00",
];
/**
* Check if a timezone value represents UTC.
*
* Handles multiple UTC representations including Zone instances, strings, and
* numeric offsets. Recognizes common UTC aliases like "GMT", "Z", "Zulu",
* "+0", "+00:00", etc.
*
* @param zone - Timezone to check (Zone, string, or number)
* @returns true if the zone represents UTC/GMT/Zulu
*
* @example
* ```typescript
* isUTC("UTC") // true
* isUTC("GMT") // true
* isUTC("Z") // true
* isUTC("Zulu") // true
* isUTC(0) // true
* isUTC("+00:00") // true
* isUTC("UTC+0") // true
* isUTC("America/New_York") // false
* isUTC("+08:00") // false
* ```
*/
function isUTC(zone) {
if (zone == null) {
return false;
}
if (typeof zone === "string" || typeof zone === "number") {
return Zulus.includes(zone);
}
if (isZone(zone)) {
return zone.isUniversal && zone.offset(Date.now()) === 0;
}
return false;
}
/**
* Check if a Zone is the library's sentinel "unset" value.
*
* The library uses a special Zone instance to represent unknown/unset
* timezones since Luxon doesn't officially support unset zones.
*
* @param zone - Zone instance to check
* @returns true if the zone is the UnsetZone sentinel value
*
* @see {@link UnsetZone}
* @see {@link UnsetZoneName}
* @see {@link UnsetZoneOffsetMinutes}
*
* @example
* ```typescript
* isZoneUnset(UnsetZone) // true
* isZoneUnset(Info.normalizeZone("UTC")) // false
* isZoneUnset(Info.normalizeZone("UTC+8")) // false
* ```
*/
function isZoneUnset(zone) {
return (isZone(zone) &&
zone.isUniversal &&
zone.offset(0) === exports.UnsetZoneOffsetMinutes);
}
/**
* Type guard to check if a Zone is valid and usable.
*
* A zone is considered valid if it:
* - Is not null/undefined
* - Has `isValid === true` (Luxon requirement)
* - Is not the library's UnsetZone sentinel
* - Has an offset within ±14 hours (the valid range for real-world timezones)
*
* This is the canonical validation check used throughout the library.
*
* @param zone - Zone to validate
* @returns true if the zone is valid and usable (type guard)
*
* @example
* ```typescript
* const zone = Info.normalizeZone("America/Los_Angeles")
* if (isZoneValid(zone)) {
* // TypeScript knows zone is Zone (not Zone | undefined)
* console.log(zone.name)
* }
*
* isZoneValid(Info.normalizeZone("invalid")) // false
* isZoneValid(Info.normalizeZone("UTC+8")) // true
* isZoneValid(UnsetZone) // false
* isZoneValid(Info.normalizeZone("UTC+20")) // false (beyond ±14 hours)
* ```
*/
function isZoneValid(zone) {
// If the zone is not a
return (isZone(zone) &&
!isZoneUnset(zone) &&
zone.isValid && // < this was already validated by isZone() but we're being explicit here
(!zone.isUniversal || validTzOffsetMinutes(zone.offset(Date.now()))) // < Assume Luxon will validate IANA zones, so it's only on us to check fixed-offset zones
);
}
/**
* Type guard to check if a value is a **valid** Luxon Zone instance.
*
* Note that this **includes** the {@link UnsetZone} sentinel value.
*
* Checks both `instanceof Zone` and constructor name to handle cross-module
* Zone instances that may not pass instanceof checks.
*
* @param value - Value to check
* @returns true if the value is a Zone instance (type guard)
*
* @example
* ```typescript
* import { Info } from "luxon"
*
* const zone = Info.normalizeZone("UTC+8")
* if (isZone(zone)) {
* // TypeScript knows zone is Zone (not unknown)
* console.log(zone.offset(Date.now()))
* }
*
* isZone(Info.normalizeZone("UTC")) // true
* isZone("UTC") // false
* isZone(480) // false
* isZone(null) // false
* ```
*/
function isZone(value) {
if (!(0, Object_1.isObject)(value))
return false;
if (value instanceof luxon_1.Zone) {
return value.isValid;
}
// Duck-typing fallback for cross-module Zone instances
const z = value;
return (z.isValid === true &&
// These are the Zone properties/methods we care about:
typeof z.isUniversal === "boolean" &&
typeof z.formatOffset === "function");
}
/**
* If `tzSource` matches this value, the tags are from a video, and we had to
* resort to assuming time fields are in UTC.
* @see https://github.com/photostructure/exiftool-vendored.js/issues/113
*/
exports.defaultVideosToUTC = "defaultVideosToUTC";
// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones -- note that
// "WET" and "W-SU" are full TZs (!!!), and "America/Indiana/Indianapolis" is
// also a thing.
const IanaFormatRE = /^\w{2,15}(?:\/\w{3,15}){0,2}$/;
/**
* Regex for parsing timezone offset strings.
*
* Supports both ISO 8601 extended format (`+08:00`) and basic format (`+0800`).
* The colon between hours and minutes is optional (`:?`) for compatibility with
* multiple standards.
*
* ## Timezone Offset Format Standards
*
* | Standard | Format | Colon Required? |
* |-----------------------|------------|-----------------|
* | ISO 8601 Basic | `-0300` | No |
* | ISO 8601 Extended | `-03:00` | Yes |
* | RFC 3339 | `-03:00` | **Yes** (strict)|
* | EXIF 2.31+ OffsetTime | `+01:00` | Yes |
* | IPTC | `+0300` | **No** |
*
* While ISO 8601 allows both formats, **mixing styles is discouraged**. A
* timestamp using extended format for date/time (e.g., `2025:04:27 19:47:08`)
* should use extended format for the offset (`-03:00`), not basic (`-0300`).
*
* ExifTool accepts both formats on input but normalizes to extended format
* (with colon) on output.
*
* @see https://en.wikipedia.org/wiki/ISO_8601 - ISO 8601 standard
* @see https://datatracker.ietf.org/doc/html/rfc3339 - RFC 3339 (colon required)
* @see https://lists.gnu.org/archive/html/bug-coreutils/2013-06/msg00023.html -
* Discussion on colon requirement for extended format
*/
const OffsetStringRE = /^(?:UTC|GMT)?(?<sign>[+−-])(?<hours>\d{1,2})(?::?(?<minutes>\d{2})(?::(?<seconds>\d{2}))?)?$/;
const MinusRE = /[−-]/;
/**
* Composable regex pattern for matching timezone offsets.
*
* Designed for embedding in larger patterns (no ^ or $ anchors).
* Matches UTC/GMT/Z or signed offsets in both ISO 8601 extended format
* (+08:00, -05:30) and basic format (+0800, -0530).
*
* Named capture groups:
* - `tz_utc`: Matches "Z", "UTC", or "GMT"
* - `tz_sign`: The sign character (+, -, or Unicode minus −)
* - `tz_hours`: Hour offset (1-2 digits)
* - `tz_minutes`: Optional minute offset (2 digits)
*
* @example
* ```typescript
* // Concatenate with other patterns
* const dateTimeRE = new RegExp(
* `(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})${TimezoneOffsetRE.source}`
* )
*
* // Use standalone
* const match = TimezoneOffsetRE.exec("2023-01-15T10:30:00-08:00")
* if (match?.groups) {
* const { tz_sign, tz_hours, tz_minutes } = match.groups
* // tz_sign = "-", tz_hours = "08", tz_minutes = "00"
* }
* ```
*/
exports.TimezoneOffsetRE = /(?:(?<tz_utc>Z|UTC|GMT)|(?<tz_sign>[+−-])(?<tz_hours>[01]?\d)(?::?(?<tz_minutes>\d\d))?)/;
/**
* Parse timezone offset from a regex match result.
*
* Use with {@link TimezoneOffsetRE} to extract offset minutes from a match.
*
* @param match - RegExp exec result from TimezoneOffsetRE
* @returns Parsed offset info, or undefined if match is invalid
*
* @example
* ```typescript
* const match = TimezoneOffsetRE.exec("2023-01-15T10:30:00-08:00")
* const result = parseTimezoneOffsetMatch(match)
* // { offsetMinutes: -480, isUtc: false }
* ```
*/
function parseTimezoneOffsetMatch(match) {
if (match?.groups == null)
return;
const { tz_utc, tz_sign, tz_hours, tz_minutes } = match.groups;
if (tz_utc != null) {
return { offsetMinutes: 0, isUtc: true };
}
if (tz_sign == null || tz_hours == null)
return;
const h = (0, Number_1.toInt)(tz_hours);
const m = (0, Number_1.toInt)(tz_minutes) ?? 0;
if (h == null || h < 0 || h > 14 || m < 0 || m >= 60)
return;
const signValue = MinusRE.test(tz_sign) ? -1 : 1;
return {
offsetMinutes: signValue * (h * 60 + m),
isUtc: false,
};
}
/**
* Parse a timezone offset string to offset minutes.
*
* Accepts multiple formats:
* - ISO 8601: "+08:00", "-05:30", "Z"
* - Luxon format: "UTC+8", "GMT-5"
* - UTC variants: "UTC", "GMT", "Zulu"
*
* Supports seconds for archaic offsets like "-00:25:21" (Ireland 1880-1916).
*
* **Note:** Does NOT validate that the offset is a real-world timezone offset.
* Use {@link validTzOffsetMinutes} for validation.
*
* @param str - Timezone offset string
* @returns Offset in minutes, or undefined if invalid
*
* @example
* ```typescript
* parseTimezoneOffsetToMinutes("+08:00") // 480
* parseTimezoneOffsetToMinutes("UTC-5") // -300
* parseTimezoneOffsetToMinutes("Z") // 0
* parseTimezoneOffsetToMinutes("-00:25:21") // -25.35 (archaic Ireland)
* parseTimezoneOffsetToMinutes("invalid") // undefined
* ```
*/
function parseTimezoneOffsetToMinutes(str) {
if (isUTC(str))
return 0;
const match = OffsetStringRE.exec(str);
if (match?.groups == null)
return;
const { hours, minutes, seconds, sign } = match.groups;
if (hours == null || sign == null)
return;
const h = (0, Number_1.toInt)(hours);
const m = (0, Number_1.toInt)(minutes) ?? 0;
const s = (0, Number_1.toInt)(seconds) ?? 0;
if (h == null || h < 0 || h > 14 || m < 0 || m >= 60 || s < 0 || s >= 60)
return;
// Handle both ASCII minus (-) and Unicode minus (−, U+2212)
const signValue = MinusRE.test(sign) ? -1 : 1;
return signValue * (h * 60 + m + s / 60);
}
/**
* Normalize a timezone input to a valid Luxon Zone.
*
* Accepts multiple input formats and returns a validated Zone instance, or
* undefined if the input cannot be normalized to a valid timezone.
*
* Supported input formats:
* - **Numbers**: Timezone offset in minutes (e.g., 480 = UTC+8, -300 = UTC-5)
* - **Strings**: ISO offsets ("+08:00", "-05:00"), IANA zones
* ("America/Los_Angeles"), UTC variants ("UTC", "GMT", "Z", "Zulu")
* - **Zone instances**: Validated and returned if valid
*
* The function respects Settings:
* - {@link Settings.allowArchaicTimezoneOffsets} for pre-1982 offsets
* - {@link Settings.allowBakerIslandTime} for UTC-12:00
*
* @param input - Timezone in various formats
* @returns Valid Zone instance, or undefined if invalid
*
* @example
* ```typescript
* // Numbers (offset in minutes)
* normalizeZone(480)?.name // "UTC+8"
* normalizeZone(-300)?.name // "UTC-5"
* normalizeZone(0)?.name // "UTC"
*
* // ISO offset strings
* normalizeZone("+08:00")?.name // "UTC+8"
* normalizeZone("-05:30")?.name // "UTC-5:30"
* normalizeZone("UTC+7")?.name // "UTC+7"
*
* // IANA timezone names
* normalizeZone("America/Los_Angeles")?.name // "America/Los_Angeles"
* normalizeZone("Asia/Tokyo")?.name // "Asia/Tokyo"
*
* // UTC aliases
* normalizeZone("UTC")?.name // "UTC"
* normalizeZone("GMT")?.name // "UTC"
* normalizeZone("Z")?.name // "UTC"
* normalizeZone("Zulu")?.name // "UTC"
*
* // Invalid inputs return undefined
* normalizeZone("invalid") // undefined
* normalizeZone("+25:00") // undefined (beyond ±14 hours)
* normalizeZone(1200) // undefined (20 hours, beyond ±14 hours)
* normalizeZone(100) // undefined (not a valid timezone offset)
* normalizeZone(-1) // undefined (UnsetZone sentinel)
* normalizeZone(null) // undefined
* ```
*/
function normalizeZone(input) {
if (input == null ||
(0, String_1.blank)(input) ||
(!(0, Number_1.isNumber)(input) && !(0, String_1.isString)(input) && !isZone(input)) ||
input === exports.UnsetZone ||
input === exports.UnsetZoneOffsetMinutes ||
isZoneUnset(input)) {
return;
}
// wrapped in a try/catch as Luxon.settings.throwOnInvalid may be true:
try {
// This test and short-circuit may not be necessary, but it's cheap and
// explicit:
if (isUTC(input)) {
return luxon_1.FixedOffsetZone.utcInstance;
}
// TypeScript-friendly: after line 386's type check, we know input is one of these
let z = input;
if ((0, Number_1.isNumber)(z) && !validTzOffsetMinutes(z)) {
return;
}
if ((0, String_1.isString)(z)) {
let s = z;
z = s = s.replace(/^(?:Zulu|Z|GMT)(?:\b|$)/i, "UTC");
// We also don't need to tease Info.normalizeZone with obviously
// non-offset inputs:
if ((0, String_1.blank)(s))
return;
const fixed = parseTimezoneOffsetToMinutes(s);
if (fixed != null && validTzOffsetMinutes(fixed)) {
return luxon_1.Info.normalizeZone(fixed);
}
if (!IanaFormatRE.test(s)) {
return;
}
}
const result = luxon_1.Info.normalizeZone(z);
return isZoneValid(result) && result.name !== exports.UnsetZoneName
? result
: undefined;
}
catch {
return;
}
}
/**
* Convert a timezone to its short offset format (e.g., "+08:00", "-05:00").
*
* Useful for displaying timezone offsets in a standardized format. For IANA
* zones with daylight saving time, provide a timestamp to get the correct
* offset for that moment.
*
* @param zone - Timezone as Zone, string, or offset in minutes
* @param ts - Optional timestamp (milliseconds) for IANA zone offset calculation.
* Defaults to current time if not provided.
* @returns Zone offset in "+HH:MM" format, or "" if zone is invalid
*
* @example
* ```typescript
* // Fixed offsets
* zoneToShortOffset("UTC+8") // "+08:00"
* zoneToShortOffset(480) // "+08:00"
* zoneToShortOffset("UTC-5:30") // "-05:30"
*
* // IANA zones (offset depends on DST)
* const winter = new Date("2023-01-15").getTime()
* const summer = new Date("2023-07-15").getTime()
* zoneToShortOffset("America/Los_Angeles", winter) // "-08:00" (PST)
* zoneToShortOffset("America/Los_Angeles", summer) // "-07:00" (PDT)
*
* // Invalid zones return empty string
* zoneToShortOffset("invalid") // ""
* zoneToShortOffset(null) // ""
* ```
*/
function zoneToShortOffset(zone, ts) {
return normalizeZone(zone)?.formatOffset(ts ?? Date.now(), "short") ?? "";
}
/**
* Type guard to check if a numeric offset (in minutes) represents a valid timezone.
*
* Validates that the offset:
* - Is a number (not null/undefined)
* - Is not the UnsetZone sentinel value (-1)
* - Matches a real-world timezone offset (respects Settings for archaic offsets)
*
* Use this for exact validation without rounding. For error-tolerant rounding to
* the nearest valid offset, use {@link inferLikelyOffsetMinutes} instead.
*
* @param tzOffsetMinutes - Offset in minutes to validate (e.g., 480 for UTC+8)
* @returns true if the offset is exactly valid (type guard)
*
* @see {@link inferLikelyOffsetMinutes} for error-tolerant rounding
*
* @example
* ```typescript
* validTzOffsetMinutes(480) // true (UTC+8)
* validTzOffsetMinutes(-300) // true (UTC-5)
* validTzOffsetMinutes(330) // true (UTC+5:30, India)
* validTzOffsetMinutes(345) // true (UTC+5:45, Nepal)
*
* validTzOffsetMinutes(481) // false (not a valid timezone)
* validTzOffsetMinutes(-1) // false (UnsetZone sentinel)
* validTzOffsetMinutes(null) // false
*
* // Archaic offsets require Settings
* Settings.allowArchaicTimezoneOffsets.value = false
* validTzOffsetMinutes(-630) // false (Hawaii -10:30, archaic)
*
* Settings.allowArchaicTimezoneOffsets.value = true
* validTzOffsetMinutes(-630) // true (Hawaii -10:30, archaic)
* ```
*/
function validTzOffsetMinutes(tzOffsetMinutes) {
return (tzOffsetMinutes != null &&
(0, Number_1.isNumber)(tzOffsetMinutes) &&
tzOffsetMinutes !== exports.UnsetZoneOffsetMinutes &&
validOffsetMinutes().has(tzOffsetMinutes));
}
/**
* Returns a "zone name" (used by `luxon`) that encodes the given offset.
* @param offsetMinutes - The timezone offset in minutes from UTC
* @returns The zone name (e.g., "UTC", "UTC+8", "UTC-5:30") or undefined if invalid
*/
function offsetMinutesToZoneName(offsetMinutes) {
if (!validTzOffsetMinutes(offsetMinutes)) {
return undefined;
}
if (offsetMinutes === 0)
return "UTC";
const sign = offsetMinutes < 0 ? "-" : "+";
const absMinutes = Math.abs(offsetMinutes);
const hours = Math.floor(absMinutes / 60);
const minutes = Math.abs(absMinutes % 60);
// luxon now renders simple hour offsets without padding:
return `UTC${sign}` + hours + (minutes === 0 ? "" : `:${(0, String_1.pad2)(minutes)}`);
}
function tzHourToOffset(n) {
return (0, Number_1.isNumber)(n) && validTzOffsetMinutes(n * 60)
? offsetMinutesToZoneName(n * 60)
: undefined;
}
// Accept "Z", "UTC+2", "UTC+02", "UTC+2:00", "UTC+02:00", "+2", "+02",
// "+02:00", and "+0200" (ISO 8601 basic format). Also accepts seconds like
// "-00:25:21" for archaic offsets. Handles Unicode minus (−, U+2212).
const tzRe = /(?<Z>Z)|((UTC)?(?<sign>[+−-])(?<hours>\d\d?)(?::?(?<minutes>\d\d)(?::(?<seconds>\d\d))?)?)$/;
function extractOffsetFromHours(hourOffset) {
return (0, Number_1.isNumber)(hourOffset)
? (0, Maybe_1.map)(tzHourToOffset(hourOffset), (zone) => ({
zone,
tz: zone,
src: "hourOffset",
}))
: Array.isArray(hourOffset)
? extractOffsetFromHours(hourOffset[0])
: undefined;
}
/**
* Extract timezone information from various value types.
*
* Handles multiple input formats and performs intelligent parsing:
* - **Strings**: ISO offsets ("+08:00"), IANA zones, UTC variants, timestamps
* with embedded timezones ("2023:01:15 10:30:00-08:00")
* - **Numbers**: Hour offsets (e.g., -8 for UTC-8)
* - **Arrays**: Uses first non-null value
* - **ExifDateTime/ExifTime instances**: Extracts their zone property
*
* By default, strips timezone abbreviations (PST, PDT, etc.) as they are
* ambiguous. Returns provenance information indicating which parsing method
* succeeded.
*
* Supports Unicode minus signs (−, U+2212) and plus-minus signs (±, U+00B1)
* in addition to ASCII +/-.
*
* @param value - Value to extract timezone from
* @param opts.stripTZA - Whether to strip timezone abbreviations (default: true).
* TZAs like "PST" are ambiguous and usually stripped.
* @returns TzSrc with zone name and provenance, or undefined if no timezone found
*
* @example
* ```typescript
* // ISO offset strings
* extractZone("+08:00")
* // { zone: "UTC+8", tz: "UTC+8", src: "offsetMinutesToZoneName" }
*
* extractZone("UTC-5:30")
* // { zone: "UTC-5:30", tz: "UTC-5:30", src: "normalizeZone" }
*
* // IANA zone names
* extractZone("America/Los_Angeles")
* // { zone: "America/Los_Angeles", tz: "America/Los_Angeles", src: "normalizeZone" }
*
* // Timestamps with embedded timezones
* extractZone("2023:01:15 10:30:00-08:00")
* // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName",
* // leftovers: "2023:01:15 10:30:00" }
*
* // Unicode minus signs
* extractZone("−08:00") // Unicode minus (U+2212)
* // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName" }
*
* // Numeric hour offsets
* extractZone(-8)
* // { zone: "UTC-8", tz: "UTC-8", src: "hourOffset" }
*
* // Arrays (uses first non-null)
* extractZone([null, "+05:30", undefined])
* // { zone: "UTC+5:30", tz: "UTC+5:30", src: "offsetMinutesToZoneName" }
*
* // Strips timezone abbreviations by default
* extractZone("2023:01:15 10:30:00-08:00 PST")
* // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName",
* // leftovers: "2023:01:15 10:30:00" }
*
* // Invalid inputs return undefined
* extractZone("invalid") // undefined
* extractZone(null) // undefined
* ```
*/
function extractZone(value, opts) {
if (value == null ||
typeof value === "boolean" ||
value instanceof BinaryField_1.BinaryField ||
value instanceof ExifDate_1.ExifDate) {
return;
}
if (Array.isArray(value)) {
// we only ever care about the first non-null value
return extractZone(value.find((ea) => ea != null));
}
if (value instanceof ExifDateTime_1.ExifDateTime || value instanceof ExifTime_1.ExifTime) {
return value.zone == null
? undefined
: {
zone: value.zone,
tz: value.zone,
src: value.constructor.name + ".zone",
};
}
if ((0, Number_1.isNumber)(value)) {
return extractOffsetFromHours(value);
}
if (typeof value !== "string" || (0, String_1.blank)(value)) {
// don't accept ExifDate, boolean, BinaryField, ResourceEvent, Struct, or
// Version instances:
return;
}
{
// If value is a proper timezone name, this may be easy!
const z = normalizeZone(value);
if (z != null) {
return { zone: z.name, tz: z.name, src: "normalizeZone" };
}
}
let str = value.trim();
// Some EXIF datetime will "over-specify" and include both the utc offset
// *and* the "time zone abbreviation"/TZA, like "PST" or "PDT". TZAs are
// between 2 (AT) and 5 (WEST) characters.
if (opts?.stripTZA !== false &&
// We only want to strip off the TZA if the input _doesn't_ end with "UTC"
// or "Z"
!/[.\d\s](?:UTC|Z)$/.test(str)) {
str = str.replace(/\s[a-z]{2,5}$/i, "");
}
{
if ((0, String_1.blank)(str))
return;
const z = normalizeZone(str);
if (z != null) {
return { zone: z.name, tz: z.name, src: "normalizeZone" };
}
}
const match = tzRe.exec(str);
const capturedGroups = match?.groups;
if (match != null && capturedGroups != null) {
const leftovers = str.slice(0, match.index);
if (capturedGroups.Z === "Z")
return {
zone: "UTC",
tz: "UTC",
src: "Z",
...((0, String_1.blank)(leftovers) ? {} : { leftovers }),
};
const hours = parseInt(capturedGroups.hours ?? "0");
const minutes = parseInt(capturedGroups.minutes ?? "0");
const seconds = parseInt(capturedGroups.seconds ?? "0");
// Handle both ASCII minus (-) and Unicode minus (−, U+2212)
const sign = MinusRE.test(capturedGroups.sign ?? "") ? -1 : 1;
const offsetMinutes = sign * (hours * 60 + minutes + seconds / 60);
const zone = offsetMinutesToZoneName(offsetMinutes);
if (zone != null) {
return {
zone,
tz: zone,
src: "offsetMinutesToZoneName",
...((0, String_1.blank)(leftovers) ? {} : { leftovers }),
};
}
}
return;
}
exports.TimezoneOffsetTagnames = [
"TimeZone",
// We **don't** look at "OffsetTime", as that is the offset for `ModifyDate`,
// which is the _file_ modification time. See
// https://github.com/photostructure/exiftool-vendored.js/issues/220 for
// details.
// time zone for DateTimeOriginal, "-08:00"
"OffsetTimeOriginal",
// time zone for CreateDate, "-08:00"
"OffsetTimeDigitized",
// srsly who came up with these wholly inconsistent tag names? _why not just
// prefix tag names with "Offset"?!11_ SADNESS AND WOE
// 1 or 2 values: 1. The time zone offset of DateTimeOriginal from GMT in
// hours, 2. If present, the time zone offset of ModifyDate (which we
// ignore) @see https://www.exiftool.org/TagNames/EXIF.html
"TimeZoneOffset", // number | number[] | string
// We DON'T use "GeolocationTimezone" here, as at this layer in the glue
// factory we don't have access to the ExifTool option "ignoreZeroZeroLatLon"
];
function incrementZone(z, minutes) {
const norm = normalizeZone(z);
if (true !== norm?.isUniversal)
return;
const fixed = norm.offset(Date.now()); // < arg doesn't matter, it's universal
return (0, Number_1.isNumber)(fixed)
? luxon_1.FixedOffsetZone.instance(fixed + minutes)
: undefined;
}
/**
* Extract timezone offset from standard EXIF timezone tags.
*
* Checks timezone tags in priority order:
* 1. TimeZone
* 2. OffsetTimeOriginal (for DateTimeOriginal)
* 3. OffsetTimeDigitized (for CreateDate)
* 4. TimeZoneOffset
*
* Handles camera-specific quirks like Nikon's DaylightSavings tag, which
* requires adjusting the TimeZone offset forward by one hour during DST.
*
* @param t - EXIF tags object
* @param opts.adjustTimeZoneIfDaylightSavings - Optional function to adjust
* timezone for DST. Defaults to handling Nikon's DaylightSavings quirk.
* @returns TzSrc with zone and provenance, or undefined if no timezone found
*
* @see {@link TimezoneOffsetTagnames} for the list of tags checked
* @see https://github.com/photostructure/exiftool-vendored.js/issues/215
*
* @example
* ```typescript
* const tags = await exiftool.read("photo.jpg")
*
* const tzSrc = extractTzOffsetFromTags(tags)
* if (tzSrc) {
* console.log(`Timezone: ${tzSrc.zone}`)
* console.log(`Source: ${tzSrc.src}`) // e.g., "OffsetTimeOriginal"
* }
*
* // Nikon DST handling
* const nikonTags = {
* TimeZone: "-08:00",
* DaylightSavings: "Yes",
* Make: "NIKON CORPORATION"
* }
* extractTzOffsetFromTags(nikonTags)
* // { zone: "UTC-7", tz: "UTC-7",
* // src: "TimeZone (adjusted for DaylightSavings)" }
* ```
*/
function extractTzOffsetFromTags(t, opts) {
const adjustFn = opts?.adjustTimeZoneIfDaylightSavings ??
DefaultExifToolOptions_1.defaultAdjustTimeZoneIfDaylightSavings;
for (const tagName of exports.TimezoneOffsetTagnames) {
const offset = extractZone(t[tagName]);
if (offset == null)
continue;
// UGH. See https://github.com/photostructure/exiftool-vendored.js/issues/215
const minutes = adjustFn(t, offset.tz);
if (minutes != null) {
const adjustedZone = incrementZone(offset.tz, minutes);
if (adjustedZone != null)
return {
zone: adjustedZone.name,
tz: adjustedZone.name,
src: tagName + " (adjusted for DaylightSavings)",
};
}
// No fancy adjustments needed, just return the extracted zone:
return { ...offset, src: tagName };
}
return;
}
function extractTzOffsetFromDatestamps(t, opts) {
if (opts?.inferTimezoneFromDatestamps === true) {
for (const tagName of opts.inferTimezoneFromDatestampTags ?? []) {
if (t[tagName] != null) {
const offset = extractZone(t[tagName]);
// Some applications (looking at you, Google Takeout) will add a
// spurious "+00:00" timezone offset to random datestamp tags, so
// ignore UTC offsets here.
if (offset != null && !isUTC(offset.tz)) {
return { ...offset, src: tagName };
}
}
}
}
return;
}
function extractTzOffsetFromTimeStamp(t, opts) {
if (opts?.inferTimezoneFromTimeStamp !== true)
return;
const ts = ExifDateTime_1.ExifDateTime.from(t.TimeStamp);
if (ts == null)
return;
for (const tagName of opts.inferTimezoneFromDatestampTags ?? []) {
const v = t[tagName];
if (!(0, String_1.isString)(v) && !(v instanceof ExifDateTime_1.ExifDateTime))
continue;
const ea = ExifDateTime_1.ExifDateTime.from(v);
if (ea == null)
continue;
if (ea.zone != null) {
return { zone: ea.zone, tz: ea.zone, src: tagName };
}
const deltaMinutes = Math.floor((ea.toEpochSeconds("UTC") - ts.toEpochSeconds()) / 60);
const likelyOffsetZone = inferLikelyOffsetMinutes(deltaMinutes);
const zone = offsetMinutesToZoneName(likelyOffsetZone);
if (zone != null) {
return {
zone,
tz: zone,
src: "offset between " + tagName + " and TimeStamp",
};
}
}
return;
}
/**
* Round an arbitrary offset to the nearest valid timezone offset.
*
* This is error-tolerant timezone inference, useful for:
* - GPS-based timezone calculation (where GPS time drift may cause errors)
* - Handling clock drift in timestamp comparisons
* - Fuzzy timezone matching
*
* By default, uses {@link Settings.maxValidOffsetMinutes} (30 minutes) as the
* maximum distance from a valid timezone. This threshold handles GPS acquisition
* lag and clock drift while preventing false matches.
*
* Respects Settings for archaic offsets, Baker Island time, and max offset tolerance.
*
* @param deltaMinutes - Offset in minutes to round (can be fractional)
* @param maxValidOffsetMinutes - Maximum distance (in minutes) from a valid
* timezone to accept. Defaults to {@link Settings.maxValidOffsetMinutes}.
* @returns Nearest valid offset in minutes, or undefined if too far from any
* valid timezone
*
* @see {@link validTzOffsetMinutes} for exact validation without rounding
* @see {@link Settings.maxValidOffsetMinutes} to configure the default threshold
*
* @example
* ```typescript
* // Exact matches
* inferLikelyOffsetMinutes(480) // 480 (UTC+8, exact)
* inferLikelyOffsetMinutes(-300) // -300 (UTC-5, exact)
*
* // Rounding within default threshold (30 minutes)
* inferLikelyOffsetMinutes(485) // 480 (UTC+8, rounded from 485)
* inferLikelyOffsetMinutes(-295) // -300 (UTC-5, rounded from -295)
* inferLikelyOffsetMinutes(330.5) // 330 (UTC+5:30, rounded)
*
* // GPS-based inference with clock drift (within 30 min default)
* const gpsTime = "2023:01:15 19:30:45" // UTC
* const localTime = "2023:01:15 11:32:12" // Local with 1.5min drift
* const deltaMinutes = 480 + 1.5 // ~481.5 minutes
* inferLikelyOffsetMinutes(deltaMinutes) // 480 (UTC+8)
*
* // GPS lag up to 23 minutes still works (within 30 min threshold)
* inferLikelyOffsetMinutes(443) // 420 (UTC-7, ~23 min from actual)
*
* // Beyond threshold returns undefined
* inferLikelyOffsetMinutes(100) // undefined (not near any valid offset)
*
* // Custom threshold
* inferLikelyOffsetMinutes(495, 30) // 480 (UTC+8 with 30min threshold)
* inferLikelyOffsetMinutes(495, 15) // undefined (beyond 15min threshold)
*
* // Adjust global default
* Settings.maxValidOffsetMinutes.value = 15 // Stricter matching
* inferLikelyOffsetMinutes(443) // undefined (beyond 15min threshold)
* ```
*/
function inferLikelyOffsetMinutes(deltaMinutes, maxValidOffsetMinutes = Settings_1.Settings.maxValidOffsetMinutes.value) {
return deltaMinutes == null
? undefined
: (0, Array_1.leastBy)(likelyOffsetMinutes(), (ea) => {
const diff = Math.abs(ea - deltaMinutes);
// Reject timezone offsets more than maxValidOffsetMinutes minutes away:
return diff > maxValidOffsetMinutes ? undefined : diff;
});
}
/**
* Convert blank strings to undefined.
*/
function toNotBlank(x) {
return x == null || (typeof x === "string" && (0, String_1.blank)(x)) ? undefined : x;
}
/**
* Infer timezone offset by comparing local time with GPS/UTC time.
*
* Calculates the timezone by finding the difference between:
* - A "captured at" timestamp (DateTimeOriginal, CreateDate, etc.) assumed to
* be in local time
* - A UTC timestamp (GPSDateTime, DateTimeUTC, or combined GPSDateStamp +
* GPSTimeStamp)
*
* Uses {@link inferLikelyOffsetMinutes} to handle minor clock drift and round
* to the nearest valid timezone offset.
*
* This is a fallback when explicit timezone tags are not available.
*
* @param t - Tags object with timestamp fields
* @returns TzSrc with inferred timezone and provenance, or undefined if
* inference is not possible
*
* @see {@link extractTzOffsetFromTags} to check explicit timezone tags first
*
* @example
* ```typescript
* // GPS-based inference
* const tags = {
* DateTimeOriginal: "2023:01:15 11:30:00", // Local time (PST)
* GPSDateTime: "2023:01:15 19:30:00" // UTC
* }
* extractTzOffsetFromUTCOffset(tags)
* // { zone: "UTC-8", tz: "UTC-8",
* // src: "offset between DateTimeOriginal and GPSDateTime" }
*
* // DateTimeUTC-based inference
* const tags2 = {
* CreateDate: "2023:07:20 14:15:30", // Local time (JST)
* DateTimeUTC: "2023:07:20 05:15:30" // UTC
* }
* extractTzOffsetFromUTCOffset(tags2)
* // { zone: "UTC+9", tz: "UTC+9",
* // src: "offset between CreateDate and DateTimeUTC" }
*
* // Handles clock drift
* const tags3 = {
* DateTimeOriginal: "2023:01:15 11:30:45", // Local with drift
* GPSDateTime: "2023:01:15 19:29:58" // UTC (old GPS fix)
* }
* extractTzOffsetFromUTCOffset(tags3)
* // Still infers UTC-8 despite ~1 minute drift
*
* // No UTC timestamp available
* const tags4 = {
* DateTimeOriginal: "2023:01:15 11:30:00"
* // No GPS or UTC timestamp
* }
* extractTzOffsetFromUTCOffset(tags4) // undefined
* ```
*/
function extractTzOffsetFromUTCOffset(t) {
const utcSources = {
...(0, Pick_1.pick)(t, "GPSDateTime", "DateTimeUTC", "SonyDateTime2"),
GPSDateTimeStamp: (0, Maybe_1.map2)(toNotBlank(t.GPSDateStamp), // Example: "2022:04:13"
toNotBlank(t.GPSTimeStamp), // Example: "23:59:41.001"
(a, b) => a + " " + b),
};
// We can always assume these are in UTC:
const utc = (0, Maybe_1.first)([
"GPSDateTime",
"DateTimeUTC",
"GPSDateTimeStamp",
"SonyDateTime2",
], (tagName) => {
const v = utcSources[tagName];
const edt = v instanceof ExifDateTime_1.ExifDateTime ? v : ExifDateTime_1.ExifDateTime.fromExifStrict(v);
const s = edt != null && (edt.zone == null || isUTC(edt.zone))
? edt.setZone("UTC", { keepLocalTime: true })?.toEpochSeconds()
: undefined;
return s != null
? {
tagName,
s,
}
: undefined;
});
if (utc == null)
return;
// If we can find any of these without a zone, the timezone should be the
// offset between this time and the GPS time.
const dt = (0, Maybe_1.first)(CapturedAtTagNames_1.CapturedAtTagNames, (tagName) => {
const edt = ExifDateTime_1.ExifDateTime.fromExifStrict(t[tagName]);
const s = edt != null && edt.zone == null
? edt.setZone("UTC", { keepLocalTime: true })?.toEpochSeconds()
: undefined;
return s != null
? {
tagName,
s,
}
: undefined;
});
if (dt == null)
return;
const diffSeconds = dt.s - utc.s;
const offsetMinutes = inferLikelyOffsetMinutes(diffSeconds / 60);
return (0, Maybe_1.map)(offsetMinutesToZoneName(offsetMinutes), (zone) => ({
zone,
tz: zone,
src: `offset between ${dt.tagName} and ${utc.tagName}`,
}));
}
/**
* Check if two timezone values are equivalent at a specific point in time.
*
* Two zones are considered equivalent if they:
* - Are the same zone (via Luxon's Zone.equals()), OR
* - Have the same offset at the specified timestamp
*
* This is useful for:
* - De-duplicating timezone records
* - Comparing zones in different formats ("UTC+5" vs "UTC+05:00")
* - Matching IANA zones to their offset at a specific time
*
* For IANA zones with DST, you can specify a timestamp to evaluate equivalence
* at that moment. This is important when comparing historical records or future
* events where DST transitions matter.
*
* @param a - First timezone (Zone, string, or offset in minutes)
* @param b - Second timezone (Zone, string, or offset in minutes)
* @param at - Timestamp in milliseconds to evaluate zone offsets.
* Defaults to current time (Date.now()).
* @returns true if zones are equivalent at the specified time
*
* @example
* ```typescript
* // Same zone, different formats
* equivalentZones("UTC+5", "UTC+05:00") // true
* equivalentZones("UTC-8", -480) // true (480 minutes = 8 hours)
* equivalentZones("GMT", "UTC") // true
* equivalentZones("Z", 0) // true
*
* // IANA zones matched by current offset (default behavior)
* equivalentZones("America/New_York", "UTC-5") // true in winter (EST)
* equivalentZones("America/New_York", "UTC-4") // true in summer (EDT)
*
* // IANA zones at specific times
* const winter = new Date("2023-01-15").getTime()
* const summer = new Date("2023-07-15").getTime()
* equivalentZones("America/New_York", "UTC-5", winter) // true (EST)
* equivalentZones("America/New_York", "UTC-4", winter) // false (not EDT in winter)
* equivalentZones("America/New_York", "UTC-4", summer) // true (EDT)
* equivalentZones("America/New_York", "UTC-5", summer) // false (not EST in summer)
*
* // Compare two IANA zones at a specific time
* equivalentZones("America/New_York", "America/Toronto", winter) // true (both EST)
* equivalentZones("America/New_York", "America/Los_Angeles", winter) // false (EST vs PST)
*
* // Different zones
* equivalentZones("UTC+8", "UTC+9") // false
*
* // Invalid zones return false
* equivalentZones("invalid", "UTC") // false
* equivalentZones(null, "UTC") // false
* ```
*/
function equivalentZones(a, b, at = Date.now()) {
const az = normalizeZone(a);
const bz = normalizeZone(b);
return (az != null &&
bz != null &&
(az.equals(bz) || az.offset(at) === bz.offset(at)));
}
function getZoneName(args = {}) {
const result = normalizeZone(args.zone)?.name ??
normalizeZone(args.zoneName)?.name ??
offsetMinutesToZoneName(args.tzoffsetMinutes);
return (0, String_1.blank)(result) || result === exports.UnsetZoneName ? undefined : result;
}
//# sourceMappingURL=Timezones.js.map