Skip to content

Commit 2be455b

Browse files
committed
GPS improvements
- coordinate parser - added apple export XMP tests - new geolocation heuristic - improve jsdocs - add tests - GPSLatitude and GPSLongitude now may be string | number
1 parent 206397e commit 2be455b

19 files changed

+1296
-185
lines changed

.vscode/settings.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"IPTC",
88
"JFIF",
99
"modif",
10-
"noexif"
10+
"noexif",
11+
"NSEW"
1112
],
1213
"cSpell.ignoreWords": [
1314
"automagick",

CHANGELOG.md

+19-1
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,31 @@ vendored versions of ExifTool match the version that they vendor.
2525

2626
## Version history
2727

28+
### v29.0.0
29+
30+
- 💔/🐞/📦 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.
31+
32+
### GPS improvements
33+
34+
- 🐞/📦 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.
35+
36+
- 🐞/📦 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.
37+
38+
- 🐞/📦 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.
39+
40+
- 🐞/📦 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.
41+
42+
- 🐞 If `ignoreZeroZeroLatLon` and `geolocation` were `true`, (0,0) location timezones could still be inferred in prior versions.
43+
44+
- 📦 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.
45+
2846
### v28.8.0
2947

3048
**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.
3149

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

34-
**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.
52+
**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.
3553

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

src/CoordinateParser.spec.ts

+166
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { expect } from "./_chai.spec"
2+
import {
3+
parseCoordinate,
4+
parseCoordinates,
5+
parseDecimalCoordinate,
6+
} from "./CoordinateParser"
7+
8+
describe("Coordinate Parser", () => {
9+
describe("parsePosition", () => {
10+
it("should parse valid DMS coordinates", () => {
11+
const input = "40° 26' 46\" N 79° 58' 56\" W"
12+
const result = parseCoordinates(input)
13+
expect(result).to.eql({
14+
latitude: 40.446111,
15+
longitude: -79.982222,
16+
})
17+
})
18+
it("should parse valid DMS coordinates from ExifTool", () => {
19+
const input = "37 deg 46' 29.64\" N, 122 deg 25' 9.85\" W"
20+
const result = parseCoordinates(input)
21+
expect(result).to.eql({
22+
latitude: 37.7749,
23+
longitude: -122.419403,
24+
})
25+
})
26+
27+
it("should parse valid DM coordinates", () => {
28+
const input = "40° 26.767' N 79° 58.933' W"
29+
const result = parseCoordinates(input)
30+
expect(result).to.eql({
31+
latitude: 40.446117,
32+
longitude: -79.982217,
33+
})
34+
})
35+
36+
it("should parse valid decimal coordinates", () => {
37+
const input = "40.44611° N 79.98222° W"
38+
const result = parseCoordinates(input)
39+
expect(result).to.eql({
40+
latitude: 40.44611,
41+
longitude: -79.98222,
42+
})
43+
})
44+
45+
it("should throw on empty input", () => {
46+
expect(() => parseCoordinates("")).to.throw(
47+
"Input string cannot be empty"
48+
)
49+
})
50+
51+
it("should throw on multiple latitude values", () => {
52+
const input = "40° N 50° N"
53+
expect(() => parseCoordinates(input)).to.throw(
54+
"Multiple latitude values found"
55+
)
56+
})
57+
})
58+
59+
describe("parseDecimalCoordinate", () => {
60+
it("should parse valid decimal coordinate", () => {
61+
const result = parseDecimalCoordinate("40.44611° N")
62+
expect(result).to.eql({
63+
degrees: 40.44611,
64+
direction: "N",
65+
})
66+
})
67+
68+
it("should throw on non-decimal format", () => {
69+
expect(() => parseDecimalCoordinate("40° 26' N")).to.throw(
70+
"Expected decimal degrees format"
71+
)
72+
})
73+
})
74+
75+
describe("parseCoordinates", () => {
76+
it("should parse multiple coordinates", () => {
77+
const input = "40° N 79° W"
78+
const result = parseCoordinates(input)
79+
expect(result).to.eql({
80+
latitude: 40,
81+
longitude: -79,
82+
})
83+
})
84+
85+
it("should handle mixed formats", () => {
86+
const input = "40° 26' 46\" N 79.98222° W"
87+
const result = parseCoordinates(input)
88+
expect(result).to.eql({
89+
latitude: 40.446111,
90+
longitude: -79.98222,
91+
})
92+
})
93+
})
94+
95+
describe("parseCoordinate", () => {
96+
it("should parse DMS format", () => {
97+
const result = parseCoordinate("40° 26' 46\" N")
98+
expect(result).to.eql({
99+
degrees: 40,
100+
minutes: 26,
101+
seconds: 46,
102+
direction: "N",
103+
format: "DMS",
104+
remainder: "",
105+
})
106+
})
107+
108+
it("should parse DM format", () => {
109+
const result = parseCoordinate("40° 26.767' N")
110+
expect(result).to.eql({
111+
degrees: 40,
112+
minutes: 26.767,
113+
seconds: undefined,
114+
direction: "N",
115+
format: "DM",
116+
remainder: "",
117+
})
118+
})
119+
120+
it("should parse decimal format", () => {
121+
const result = parseCoordinate("40.44611° N")
122+
expect(result).to.eql({
123+
degrees: 40.44611,
124+
minutes: undefined,
125+
seconds: undefined,
126+
direction: "N",
127+
format: "D",
128+
remainder: "",
129+
})
130+
})
131+
132+
it("should handle negative degrees", () => {
133+
const result = parseCoordinate("-40.44611° S")
134+
expect(result.degrees).to.eql(-40.44611)
135+
})
136+
137+
it("should handle remainder text", () => {
138+
const result = parseCoordinate("40° N Additional Text")
139+
expect(result.remainder).to.eql("Additional Text")
140+
})
141+
142+
it("should throw on invalid minutes", () => {
143+
expect(() => parseCoordinate("40° 60' N")).to.throw(
144+
"Minutes must be between 0 and 59"
145+
)
146+
})
147+
148+
it("should throw on invalid seconds", () => {
149+
expect(() => parseCoordinate("40° 30' 60\" N")).to.throw(
150+
"Seconds must be between 0 and 59"
151+
)
152+
})
153+
154+
it("should throw on invalid latitude degrees", () => {
155+
expect(() => parseCoordinate("91° N")).to.throw(
156+
"Degrees must be between -90 and 90"
157+
)
158+
})
159+
160+
it("should throw on invalid longitude degrees", () => {
161+
expect(() => parseCoordinate("181° E")).to.throw(
162+
"Degrees must be between -180 and 180"
163+
)
164+
})
165+
})
166+
})

0 commit comments

Comments
 (0)