exiftool-vendored
Version:
Efficient, cross-platform access to ExifTool
539 lines • 20.2 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.TimezoneOffsetTagnames = exports.defaultVideosToUTC = exports.UnsetZoneName = exports.UnsetZone = exports.UnsetZoneOffsetMinutes = void 0;
exports.isUTC = isUTC;
exports.isZoneUnset = isZoneUnset;
exports.isZoneValid = isZoneValid;
exports.isZone = isZone;
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 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.
const ValidTimezoneOffsets = [
// "-12:00", // not used for any populated land
"-11:00",
// "-10:30", // used by Hawaii 1896-1947
"-10:00",
"-09:30",
"-09:00",
"-08:30",
"-08:00",
"-07:00",
"-06:00",
"-05:00",
"-04:30", // used by Venezuela 1912-1965 and 2007-2016
"-04:00",
"-03:30",
"-03:00",
"-02:30",
"-02:00",
"-01:00",
// "-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:00",
// "+00:20", // used by Netherlands until 1940
// "+00:30", // used by Switzerland until 1936
"+01:00",
// "+01:24", // used by Warsaw until 1915
// "+01:30", // used by some southern African countries until 1903
"+02:00",
// "+02:30", // archaic Moscow time
"+03:00",
"+03:30",
"+04:00",
"+04:30",
// "+04:51", // used by Bombay until 1955 https://en.wikipedia.org/wiki/UTC%2B04:51
"+05:00",
"+05:30",
// "+05:40", // used by Nepal until 1920
"+05:45", // Nepal
"+06:00",
"+06:30",
"+07:00",
// "+07:20", // used by Singapore and Malaya until 1941
"+07:30", // used by Mayasia until 1982
"+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",
];
function offsetToMinutes(offset) {
const [h, m] = offset.split(":").map(Number);
// we can't just return `h * 60 + m`: that doesn't work with negative
// offsets (minutes will be positive but hours will be negative)
const sign = h < 0 ? -1 : 1;
return h * 60 + sign * m;
}
const ValidOffsetMinutes = (0, Lazy_1.lazy)(() => new Set(ValidTimezoneOffsets.map(offsetToMinutes)));
/**
* 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,
"UTC",
"GMT",
"Z",
"+0",
"+00:00",
"UTC+0",
"GMT+0",
"UTC+00:00",
"GMT+00:00",
];
function isUTC(zone) {
if (zone == null) {
return false;
}
if (typeof zone === "string" || typeof zone === "number") {
return Zulus.includes(zone);
}
if (zone instanceof luxon_1.Zone) {
return zone.isUniversal && zone.offset(Date.now()) === 0;
}
return false;
}
function isZoneUnset(zone) {
return zone.isUniversal && zone.offset(0) === exports.UnsetZoneOffsetMinutes;
}
function isZoneValid(zone) {
return (zone != null &&
zone.isValid &&
!isZoneUnset(zone) &&
Math.abs(zone.offset(Date.now())) < 14 * 60);
}
function isZone(zone) {
return ((0, Object_1.isObject)(zone) && (zone instanceof luxon_1.Zone || zone.constructor.name === "Zone"));
}
/**
* 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}$/;
// Luxon requires fixed-offset zones to look like "UTC+H", "UTC-H",
// "UTC+H:mm", "UTC-H:mm":
const FixedFormatRE = /^UTC(?<sign>[+-])(?<hours>\d+)(?::(?<minutes>\d{2}))?$/;
function parseFixedOffset(str) {
const match = FixedFormatRE.exec(str)?.groups;
if (match == null)
return;
const h = (0, Number_1.toInt)(match.hours);
const m = (0, Number_1.toInt)(match.minutes) ?? 0;
if (h == null || h < 0 || h > 14 || m < 0 || m >= 60)
return;
const result = (match.sign === "-" ? -1 : 1) * (h * 60 + m);
return (ValidOffsetMinutes().has(result) ? result : undefined) ?? undefined;
}
/**
* @param input must be either a number, which is the offset in minutes, or a
* string in the format "UTC+H" or "UTC+HH:mm"
*/
function normalizeZone(input) {
if (input == null ||
(0, String_1.blank)(input) ||
(!(0, Number_1.isNumber)(input) && !(0, String_1.isString)(input) && !isZone(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;
let z = input;
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 = parseFixedOffset(s);
if (fixed != null) {
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;
}
}
/**
* @param ts must be provided if the zone is not a fixed offset
* @return the zone offset (in "±HH:MM" format) for the given zone, or "" if
* the zone is invalid
*/
function zoneToShortOffset(zone, ts) {
return normalizeZone(zone)?.formatOffset(ts ?? Date.now(), "short") ?? "";
}
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.
*/
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", and
// "+02:00". Require the sign (+ or -) and a ":" separator if there are
// minutes.
const tzRe = /(?<Z>Z)|((UTC)?(?<sign>[+-])(?<hours>\d\d?)(?::(?<minutes>\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;
}
/**
* Parse a timezone offset and return the offset minutes
*
* @param opts.stripTZA If false, do not strip off the timezone abbreviation
* (TZA) from the value. Defaults to true.
*
* @return undefined if the value cannot be parsed as a valid timezone offset
*/
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 offsetMinutes = (capturedGroups.sign === "-" ? -1 : 1) *
(parseInt(capturedGroups.hours ?? "0") * 60 +
parseInt(capturedGroups.minutes ?? "0"));
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.
// 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 (norm == null || 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;
}
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;
}
// timezone offsets may be on a 15 minute boundary, but if GPS acquisition is
// old, this can be spurious. We get less mistakes with a larger multiple, so
// we're using 30 minutes instead of 15. See
// https://www.timeanddate.com/time/time-zones-interesting.html
const LikelyOffsetMinutes = ValidTimezoneOffsets.map(offsetToMinutes);
function inferLikelyOffsetMinutes(deltaMinutes) {
const nearest = (0, Array_1.leastBy)(LikelyOffsetMinutes, (ea) => Math.abs(ea - deltaMinutes));
// Reject timezone offsets more than 30 minutes away from the nearest:
return nearest != null && Math.abs(nearest - deltaMinutes) < 30
? nearest
: undefined;
}
/**
* Convert blank strings to undefined.
*/
function toNotBlank(x) {
return x == null || (typeof x === "string" && (0, String_1.blank)(x)) ? undefined : x;
}
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}`,
}));
}
function equivalentZones(a, b) {
const az = normalizeZone(a);
const bz = normalizeZone(b);
return (az != null &&
bz != null &&
(az.equals(bz) || az.offset(Date.now()) === bz.offset(Date.now())));
}
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
;