exiftool-vendored
Version:
Efficient, cross-platform access to ExifTool
295 lines • 11.7 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseCoordinates = parseCoordinates;
exports.parseDecimalCoordinate = parseDecimalCoordinate;
exports.parseCoordinate = parseCoordinate;
exports.roundGpsDecimal = roundGpsDecimal;
exports.parsePosition = parsePosition;
exports.processCoordinate = processCoordinate;
const Number_1 = require("./Number");
const String_1 = require("./String");
// Constants
const MAX_LATITUDE_DEGREES = 90;
const MAX_LONGITUDE_DEGREES = 180;
class CoordinateParseError extends Error {
constructor(message) {
super(message);
this.name = "CoordinateParseError";
}
}
// Regex to match simple decimal coordinates, like "37.5, -122.5"
const DecimalCoordsRE = /^(-?\d+(?:\.\d+)?)[,\s]+(-?\d+(?:\.\d+)?)$/;
/**
* Parses a string containing both latitude and longitude coordinates.
* @param input - String containing both coordinates
* @returns Object containing latitude and longitude in decimal degrees, or undefined if parsing fails
* @throws CoordinateParseError if the input format is invalid
*/
function parseCoordinates(input) {
input = (0, String_1.toS)(input).trim();
if (input.length === 0) {
throw new CoordinateParseError("Input string cannot be empty");
}
if (DecimalCoordsRE.test(input)) {
const split = input.split(/[\s,]+/);
const [latitude, longitude] = split
.map(Number_1.toFloat)
.map((ea) => (ea == null ? null : roundGpsDecimal(ea)));
if (latitude == null || longitude == null) {
throw new CoordinateParseError("Failed to parse decimal coordinates");
}
return { latitude, longitude };
}
let latitude;
let longitude;
for (const coord of parseStringCoordinates(input)) {
if (!coord.direction) {
throw new CoordinateParseError("Direction is required for position parsing");
}
if (coord.direction === "S" || coord.direction === "N") {
if (latitude !== undefined) {
throw new CoordinateParseError("Multiple latitude values found");
}
latitude = toDecimalDegrees(coord);
}
else {
if (longitude != null) {
throw new CoordinateParseError("Multiple longitude values found");
}
longitude = toDecimalDegrees(coord);
}
}
const missing = [];
if (latitude == null)
missing.push("latitude");
if (longitude == null)
missing.push("longitude");
if (latitude == null || longitude == null) {
throw new CoordinateParseError(`Missing ${missing.join(" and ")}`);
}
else {
return { latitude, longitude };
}
}
/**
* Parses a string containing one or more coordinates.
* @param input - String containing coordinates
* @returns Array of parsed coordinates
*/
function parseStringCoordinates(input) {
if (!input?.trim()) {
throw new CoordinateParseError("Input string cannot be empty");
}
const lat = parseCoordinate(input, true);
const remainders = lat.remainder;
if ((0, String_1.blank)(remainders)) {
throw new CoordinateParseError("Expected multiple coordinates");
}
return [lat, parseCoordinate(remainders)];
}
/**
* Parses a coordinate string in decimal degrees format.
* @param input - String containing a single coordinate
* @returns Object containing degrees and direction, or undefined if parsing fails
* @throws CoordinateParseError if the format is not decimal degrees or direction is missing
*/
function parseDecimalCoordinate(input) {
if (!input?.trim()) {
throw new CoordinateParseError("Input string cannot be empty");
}
const coord = parseCoordinate(input);
if (coord.format !== "D") {
throw new CoordinateParseError("Expected decimal degrees format");
}
if (!coord.direction) {
throw new CoordinateParseError("Missing direction");
}
return { decimal: toDecimalDegrees(coord), direction: coord.direction };
}
const DecimalCoordRE = /^(-?\d+(?:\.\d+)?)$/;
/**
* Parses a single coordinate string into its components.
* @param input - String containing a single coordinate
* @param expectRemainders - If true, allow additional text after the coordinate
* @returns Parsed coordinate object
* @throws CoordinateParseError if the format is invalid
*/
function parseCoordinate(input, expectRemainders = false) {
input = (0, String_1.toS)(input).trim();
if (input.length === 0) {
throw new CoordinateParseError("Input string cannot be empty");
}
if (DecimalCoordRE.test(input)) {
const f = (0, Number_1.toFloat)(input);
if (f == null) {
throw new CoordinateParseError("Failed to parse decimal coordinate");
}
const r = roundGpsDecimal(f);
return {
degrees: r,
decimal: r,
format: "D",
direction: undefined,
minutes: undefined,
seconds: undefined,
remainder: "",
};
}
const dmsPattern = /^(?<degrees>-?\d+)\s*(?:°|DEG)\s*(?<minutes>\d+)\s*['′]\s*(?<seconds>\d+(?:\.\d+)?)\s*["″]\s?(?<direction>[NSEW])?[\s,]{0,3}(?<remainder>.*)$/i;
const dmPattern = /^(?<degrees>-?\d+)\s*(?:°|DEG)\s*(?<minutes>\d+(?:\.\d+)?)\s?['′]\s?(?<direction>[NSEW])?(?<remainder>.*)$/i;
const dPattern = /^(?<degrees>-?\d+(?:\.\d+)?)\s*(?:°|DEG)\s?(?<direction>[NSEW])?(?<remainder>.*)$/i;
const trimmedInput = input.trimStart();
let match;
let format = null;
if ((match = trimmedInput.match(dmsPattern))) {
format = "DMS";
}
else if ((match = trimmedInput.match(dmPattern))) {
format = "DM";
}
else if ((match = trimmedInput.match(dPattern))) {
format = "D";
}
if (match == null ||
format == null ||
(!expectRemainders && !(0, String_1.blank)(match?.groups?.remainder))) {
throw new CoordinateParseError("Invalid coordinate format. Expected one of:\n" +
" DDD° MM' SS.S\" k (deg/min/sec)\n" +
" DDD° MM.MMM' k (deg/decimal minutes)\n" +
" DDD.DDDDD° (decimal degrees)\n" +
" (where k indicates direction: N, S, E, or W)");
}
if (!match.groups) {
throw new CoordinateParseError("Failed to parse coordinate components");
}
const { degrees: degreesStr, minutes: minutesStr, seconds: secondsStr, direction: directionStr, remainder, } = match.groups;
const direction = directionStr?.toUpperCase();
if (degreesStr == null) {
throw new CoordinateParseError("Missing degrees in coordinate");
}
const degrees = parseFloat(degreesStr);
let minutes;
let seconds;
if (format === "DMS") {
if (minutesStr == null || secondsStr == null) {
throw new CoordinateParseError("Missing minutes or seconds in DMS coordinate");
}
minutes = parseInt(minutesStr, 10);
seconds = parseFloat(secondsStr);
if (minutes >= 60) {
throw new CoordinateParseError("Minutes must be between 0 and 59");
}
if (seconds >= 60) {
throw new CoordinateParseError("Seconds must be between 0 and 59.999...");
}
}
else if (format === "DM") {
if (minutesStr == null) {
throw new CoordinateParseError("Missing minutes in DM coordinate");
}
minutes = parseFloat(minutesStr);
if (minutes >= 60) {
throw new CoordinateParseError("Minutes must be between 0 and 59.999...");
}
}
const maxDegrees = direction === "N" || direction === "S"
? MAX_LATITUDE_DEGREES
: MAX_LONGITUDE_DEGREES;
if (Math.abs(degrees) > maxDegrees) {
throw new CoordinateParseError(`Degrees must be between -${maxDegrees} and ${maxDegrees} for ${direction} direction`);
}
const coords = {
degrees,
minutes,
seconds,
direction,
format,
remainder: remainder?.trim(),
};
const decimal = toDecimalDegrees(coords);
return {
...coords,
decimal,
};
}
function toDecimalDegrees(coord) {
const degrees = (0, Number_1.toFloat)(coord.degrees) ?? 0;
const sign = Math.sign(degrees);
let decimal = Math.abs(degrees);
decimal += Math.abs((0, Number_1.toFloat)(coord.minutes) ?? 0) / 60.0;
decimal += Math.abs((0, Number_1.toFloat)(coord.seconds) ?? 0) / 3600.0;
if (coord.direction === "S" || coord.direction === "W" || sign < 0) {
decimal = -decimal;
}
const maxDegrees = coord.direction === "N" || coord.direction === "S"
? MAX_LATITUDE_DEGREES
: MAX_LONGITUDE_DEGREES;
const axis = coord.direction === "N" || coord.direction === "S"
? "latitude"
: "longitude";
if (Math.abs(decimal) > maxDegrees) {
throw new CoordinateParseError(`Degrees must be between -${maxDegrees} and ${maxDegrees} for ${axis}`);
}
// Round to 6 decimal places
// Most consumer devices can only resolve 4-5 decimal places (1m resolution)
return roundGpsDecimal(decimal);
}
const MAX_LAT_LON_DIFF = 1;
function roundGpsDecimal(decimal) {
return (0, Number_1.roundToDecimalPlaces)(decimal, 6);
}
function parsePosition(position) {
if ((0, String_1.blank)(position))
return;
const [lat, lon] = (0, String_1.toS)(position).split(/[, ]+/).map(Number_1.toFloat);
return lat != null && lon != null ? [lat, lon] : undefined;
}
function processCoordinate(config, warnings) {
let { value, ref } = config;
const { geoValue, coordinateType } = config;
const { expectedRefPositive, expectedRefNegative, max } = config;
let isInvalid = false;
// Validate ref is reasonable -- it should either start with
// expectedRefPositive or expectedRefNegative:
ref = (0, String_1.toS)(ref).trim().toUpperCase().slice(0, 1);
if (!(0, String_1.blank)(config.ref) &&
ref !== expectedRefPositive &&
ref !== expectedRefNegative) {
warnings.push(`Invalid GPS${coordinateType}Ref: ${JSON.stringify(config.ref)}.`);
ref = value < 0 ? expectedRefNegative : expectedRefPositive;
}
// Check range
if (Math.abs(value) > max) {
isInvalid = true;
warnings.push(`Invalid GPS${coordinateType}: ${value} is out of range`);
return { value, ref, isInvalid };
}
// Apply hemisphere reference
if (ref === expectedRefNegative) {
value = -Math.abs(value);
}
// Check for mismatched signs with GeolocationPosition
if (geoValue != null &&
Math.abs(Math.abs(geoValue) - Math.abs(value)) < MAX_LAT_LON_DIFF) {
if (Math.sign(geoValue) !== Math.sign(value)) {
value = -value;
warnings.push(`Corrected GPS${coordinateType} sign based on GeolocationPosition`);
}
// Force ref to correct value
const expectedRef = geoValue < 0 ? expectedRefNegative : expectedRefPositive;
if (ref !== expectedRef) {
ref = expectedRef;
if (!(0, String_1.blank)(config.ref)) {
warnings.push(`Corrected GPS${coordinateType}Ref to ${expectedRef} based on GeolocationPosition`);
}
}
}
// Ensure ref matches coordinate sign
const expectedRef = value < 0 ? expectedRefNegative : expectedRefPositive;
if (ref != null && ref !== expectedRef && !(0, String_1.blank)(config.ref)) {
warnings.push(`Corrected GPS${coordinateType}Ref to ${ref} to match coordinate sign`);
}
ref = expectedRef;
return { value: roundGpsDecimal(value), ref, isInvalid };
}
//# sourceMappingURL=CoordinateParser.js.map
;