Skip to content

Commit

Permalink
GPS improvements
Browse files Browse the repository at this point in the history
- coordinate parser
- added apple export XMP tests
- new geolocation heuristic
- improve jsdocs
- add tests
- GPSLatitude and GPSLongitude now may be string | number
  • Loading branch information
mceachen committed Nov 4, 2024
1 parent 206397e commit 2be455b
Show file tree
Hide file tree
Showing 19 changed files with 1,296 additions and 185 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"IPTC",
"JFIF",
"modif",
"noexif"
"noexif",
"NSEW"
],
"cSpell.ignoreWords": [
"automagick",
Expand Down
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,31 @@ vendored versions of ExifTool match the version that they vendor.

## Version history

### v29.0.0

- 💔/🐞/📦 ExifTool sometimes returns `boolean` values for some tags, like `SemanticStylePreset`, but uses "Yes" or "No" values for other tags, like `GPSValid` (TIL!). If the tag name ends in `Valid` and is truthy (1, true, "Yes") or falsy (0, false, "No"), we'll convert it to a boolean for you. Note that this is arguably a breaking API change, but it should be what you were already expecting (so is it a bug fix?). See the diff to the Tags interface in this version to verify what types have changed.

### GPS improvements

- 🐞/📦 GPS Latitude and GPS Longitude values are now parsed from [DMS notation](<https://en.wikipedia.org/wiki/Degree_(angle)#Subdivisions>), which seems to avoid some incorrectly signed values in some file formats (especially for some problematic XMP exports, like from Apple Photos). Numeric GPSLatitude and GPSLongitude are still accepted: to avoid the new coordinates parsing code, restore `GPSLatitude` and `GPSLongitude` to the `ExifToolOptions.numericTags` array.

- 🐞/📦 If `ExifToolOptions.geolocation` is enabled, and `GeolocationPosition` exists, and we got numeric GPS coordinates, we will assume the hemisphere from GeolocationPosition, as that tag seems to correct for more conditions than GPS\*Ref values.

- 🐞/📦 If the encoded GPS location is invalid, all `GPS*` and `Geolocation*` metadata will be omitted from `ExifTool.readTags()`. Prior versions let some values (like `GPSCoordinates`) from invalid values slip by. A location is invalid if latitude and longitude are 0, out of bounds, either are unspecified.

- 🐞/📦 Reading and writing GPS latitude and GPS longitude values is surprisingly tricky, and could fail for some file formats due to inconsistent handling of negative values. Now, within `ExifTool.writeTags()`, we will automatically set `GPSLatitudeRef` and `GPSLongitudeRef` if lat/lon are provided but references are unspecified. More tests were added to verify this workaround. On reads, `GPSLatitudeRef` and `GPSLongitudeRef` will be backfilled to be correct. Note that they only return `"N" | "S" | "E" | "W"` now, rather than possibly being the full cardinal direction name.

- 🐞 If `ignoreZeroZeroLatLon` and `geolocation` were `true`, (0,0) location timezones could still be inferred in prior versions.

- 📦 GPS coordinates are now round to 6 decimal places (≈11cm precision). This exceeds consumer GPS accuracy while simplifying test assertions and reducing noise in comparisons. Previously storing full float precision added complexity without practical benefit.

### v28.8.0

**Important:** ExifTool versions use the format `NN.NN` and do not follow semantic versioning. The version from ExifTool will not parse correctly with the `semver` library (for the next 10 versions) since they are zero- padded.

- 🌱 Upgraded ExifTool to version [13.00](https://exiftool.org/history.html#13.00)

**Note:** ExifTool version numbers increment by 0.01 and do not follow semantic versioning conventions. The changes between version 12.99 and 13.00 are minor updates without any known breaking changes.
**Note:** ExifTool version numbers increment by 0.01 and do not follow semantic versioning conventions. The changes between version 12.99 and 13.00 are minor updates without any known breaking changes.

- 📦 Added Node.js v23 to the build matrix.

Expand Down
166 changes: 166 additions & 0 deletions src/CoordinateParser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { expect } from "./_chai.spec"
import {
parseCoordinate,
parseCoordinates,
parseDecimalCoordinate,
} from "./CoordinateParser"

describe("Coordinate Parser", () => {
describe("parsePosition", () => {
it("should parse valid DMS coordinates", () => {
const input = "40° 26' 46\" N 79° 58' 56\" W"
const result = parseCoordinates(input)
expect(result).to.eql({
latitude: 40.446111,
longitude: -79.982222,
})
})
it("should parse valid DMS coordinates from ExifTool", () => {
const input = "37 deg 46' 29.64\" N, 122 deg 25' 9.85\" W"
const result = parseCoordinates(input)
expect(result).to.eql({
latitude: 37.7749,
longitude: -122.419403,
})
})

it("should parse valid DM coordinates", () => {
const input = "40° 26.767' N 79° 58.933' W"
const result = parseCoordinates(input)
expect(result).to.eql({
latitude: 40.446117,
longitude: -79.982217,
})
})

it("should parse valid decimal coordinates", () => {
const input = "40.44611° N 79.98222° W"
const result = parseCoordinates(input)
expect(result).to.eql({
latitude: 40.44611,
longitude: -79.98222,
})
})

it("should throw on empty input", () => {
expect(() => parseCoordinates("")).to.throw(
"Input string cannot be empty"
)
})

it("should throw on multiple latitude values", () => {
const input = "40° N 50° N"
expect(() => parseCoordinates(input)).to.throw(
"Multiple latitude values found"
)
})
})

describe("parseDecimalCoordinate", () => {
it("should parse valid decimal coordinate", () => {
const result = parseDecimalCoordinate("40.44611° N")
expect(result).to.eql({
degrees: 40.44611,
direction: "N",
})
})

it("should throw on non-decimal format", () => {
expect(() => parseDecimalCoordinate("40° 26' N")).to.throw(
"Expected decimal degrees format"
)
})
})

describe("parseCoordinates", () => {
it("should parse multiple coordinates", () => {
const input = "40° N 79° W"
const result = parseCoordinates(input)
expect(result).to.eql({
latitude: 40,
longitude: -79,
})
})

it("should handle mixed formats", () => {
const input = "40° 26' 46\" N 79.98222° W"
const result = parseCoordinates(input)
expect(result).to.eql({
latitude: 40.446111,
longitude: -79.98222,
})
})
})

describe("parseCoordinate", () => {
it("should parse DMS format", () => {
const result = parseCoordinate("40° 26' 46\" N")
expect(result).to.eql({
degrees: 40,
minutes: 26,
seconds: 46,
direction: "N",
format: "DMS",
remainder: "",
})
})

it("should parse DM format", () => {
const result = parseCoordinate("40° 26.767' N")
expect(result).to.eql({
degrees: 40,
minutes: 26.767,
seconds: undefined,
direction: "N",
format: "DM",
remainder: "",
})
})

it("should parse decimal format", () => {
const result = parseCoordinate("40.44611° N")
expect(result).to.eql({
degrees: 40.44611,
minutes: undefined,
seconds: undefined,
direction: "N",
format: "D",
remainder: "",
})
})

it("should handle negative degrees", () => {
const result = parseCoordinate("-40.44611° S")
expect(result.degrees).to.eql(-40.44611)
})

it("should handle remainder text", () => {
const result = parseCoordinate("40° N Additional Text")
expect(result.remainder).to.eql("Additional Text")
})

it("should throw on invalid minutes", () => {
expect(() => parseCoordinate("40° 60' N")).to.throw(
"Minutes must be between 0 and 59"
)
})

it("should throw on invalid seconds", () => {
expect(() => parseCoordinate("40° 30' 60\" N")).to.throw(
"Seconds must be between 0 and 59"
)
})

it("should throw on invalid latitude degrees", () => {
expect(() => parseCoordinate("91° N")).to.throw(
"Degrees must be between -90 and 90"
)
})

it("should throw on invalid longitude degrees", () => {
expect(() => parseCoordinate("181° E")).to.throw(
"Degrees must be between -180 and 180"
)
})
})
})
Loading

0 comments on commit 2be455b

Please sign in to comment.