Skip to content

Commit

Permalink
Updates for GPS & location handling (#111)
Browse files Browse the repository at this point in the history
* Update to Kotlin 2.1.10
* Improved `GpsCoordinates` class and added unit test
* Introduced `MetadataUpdate.GpsCoordinatesAndLocationShown` to one-shot
update GPS & location info.
  • Loading branch information
StefanOltmann authored Jan 29, 2025
1 parent cea798f commit 13036dc
Show file tree
Hide file tree
Showing 22 changed files with 386 additions and 29 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Kim - Kotlin Image Metadata

[![Kotlin](https://img.shields.io/badge/kotlin-2.1.0-blue.svg?logo=kotlin)](httpw://kotlinlang.org)
[![Kotlin](https://img.shields.io/badge/kotlin-2.1.10-blue.svg?logo=kotlin)](httpw://kotlinlang.org)
![JVM](https://img.shields.io/badge/-JVM-gray.svg?style=flat)
![Android](https://img.shields.io/badge/-Android-gray.svg?style=flat)
![iOS](https://img.shields.io/badge/-iOS-gray.svg?style=flat)
Expand Down Expand Up @@ -39,7 +39,7 @@ of Ashampoo Photo Organizer, which, in turn, is driven by user community feedbac
## Installation

```
implementation("com.ashampoo:kim:0.21")
implementation("com.ashampoo:kim:0.22")
```

For the targets `wasmJs` & `js` you also need to specify this:
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

plugins {
kotlin("multiplatform") version "2.1.0"
kotlin("multiplatform") version "2.1.10"
id("com.android.library") version "8.5.0"
id("maven-publish")
id("signing")
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ internal object JpegUpdater : MetadataUpdater {
update !is MetadataUpdate.Orientation &&
update !is MetadataUpdate.TakenDate &&
update !is MetadataUpdate.Description &&
update !is MetadataUpdate.GpsCoordinates
update !is MetadataUpdate.GpsCoordinates &&
update !is MetadataUpdate.GpsCoordinatesAndLocationShown
)
return inputBytes

Expand Down Expand Up @@ -191,6 +192,7 @@ internal object JpegUpdater : MetadataUpdater {
update !is MetadataUpdate.Title &&
update !is MetadataUpdate.Description &&
update !is MetadataUpdate.LocationShown &&
update !is MetadataUpdate.GpsCoordinatesAndLocationShown &&
update !is MetadataUpdate.Keywords
)
return inputBytes
Expand Down Expand Up @@ -246,6 +248,32 @@ internal object JpegUpdater : MetadataUpdater {
}
}

if (update is MetadataUpdate.GpsCoordinatesAndLocationShown) {

newRecords.addAll(
oldRecords.filter {
it.iptcType != IptcTypes.CITY &&
it.iptcType != IptcTypes.PROVINCE_STATE &&
it.iptcType != IptcTypes.COUNTRY_PRIMARY_LOCATION_NAME
}
)

if (update.locationShown != null) {

update.locationShown.city?.let { city ->
newRecords.add(IptcRecord(IptcTypes.CITY, city))
}

update.locationShown.state?.let { state ->
newRecords.add(IptcRecord(IptcTypes.PROVINCE_STATE, state))
}

update.locationShown.country?.let { country ->
newRecords.add(IptcRecord(IptcTypes.COUNTRY_PRIMARY_LOCATION_NAME, country))
}
}
}

if (update is MetadataUpdate.Keywords) {

newRecords.addAll(oldRecords.filter { it.iptcType != IptcTypes.KEYWORDS })
Expand Down
12 changes: 7 additions & 5 deletions src/commonMain/kotlin/com/ashampoo/kim/format/jxl/JxlUpdater.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ internal object JxlUpdater : MetadataUpdater {

val updatedXmp = XmpWriter.updateXmp(xmpMeta, update, true)

val isExifUpdate = update is MetadataUpdate.Orientation ||
update is MetadataUpdate.TakenDate ||
update is MetadataUpdate.Description ||
update is MetadataUpdate.GpsCoordinates
val isExifUpdate =
update is MetadataUpdate.Orientation ||
update is MetadataUpdate.TakenDate ||
update is MetadataUpdate.Description ||
update is MetadataUpdate.GpsCoordinates ||
update is MetadataUpdate.GpsCoordinatesAndLocationShown

val exifBytes: ByteArray? = if (isExifUpdate) {

Expand Down Expand Up @@ -131,6 +133,6 @@ internal object JxlUpdater : MetadataUpdater {
xmp = null // No change to XMP
)

return byteWriter.toByteArray()
return@tryWithImageWriteException byteWriter.toByteArray()
}
}
12 changes: 7 additions & 5 deletions src/commonMain/kotlin/com/ashampoo/kim/format/png/PngUpdater.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ internal object PngUpdater : MetadataUpdater {

val updatedXmp = XmpWriter.updateXmp(xmpMeta, update, true)

val isExifUpdate = update is MetadataUpdate.Orientation ||
update is MetadataUpdate.TakenDate ||
update is MetadataUpdate.Description ||
update is MetadataUpdate.GpsCoordinates
val isExifUpdate =
update is MetadataUpdate.Orientation ||
update is MetadataUpdate.TakenDate ||
update is MetadataUpdate.Description ||
update is MetadataUpdate.GpsCoordinates ||
update is MetadataUpdate.GpsCoordinatesAndLocationShown

val exifBytes: ByteArray? = if (isExifUpdate) {

Expand Down Expand Up @@ -131,6 +133,6 @@ internal object PngUpdater : MetadataUpdater {
xmp = null // No change to XMP
)

return@updateThumbnail byteWriter.toByteArray()
return@tryWithImageWriteException byteWriter.toByteArray()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ public class TiffOutputSet(
setGpsCoordinates(update.gpsCoordinates)
}

is MetadataUpdate.GpsCoordinatesAndLocationShown -> {

setGpsCoordinates(update.gpsCoordinates)
}

else -> throw ImageWriteException("Can't perform update $update.")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ internal object WebPUpdater : MetadataUpdater {

val updatedXmp = XmpWriter.updateXmp(xmpMeta, update, true)

val isExifUpdate = update is MetadataUpdate.Orientation ||
update is MetadataUpdate.TakenDate ||
update is MetadataUpdate.Description ||
update is MetadataUpdate.GpsCoordinates
val isExifUpdate =
update is MetadataUpdate.Orientation ||
update is MetadataUpdate.TakenDate ||
update is MetadataUpdate.Description ||
update is MetadataUpdate.GpsCoordinates ||
update is MetadataUpdate.GpsCoordinatesAndLocationShown

val exifBytes: ByteArray? = if (isExifUpdate) {

Expand Down Expand Up @@ -124,6 +126,6 @@ internal object WebPUpdater : MetadataUpdater {
xmp = null // No change to XMP
)

return byteWriter.toByteArray()
return@tryWithImageWriteException byteWriter.toByteArray()
}
}
32 changes: 32 additions & 0 deletions src/commonMain/kotlin/com/ashampoo/kim/format/xmp/XmpWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,38 @@ public object XmpWriter {
)
}

is MetadataUpdate.GpsCoordinatesAndLocationShown -> {

/* GPS */

if (update.gpsCoordinates != null)
setGpsCoordinates(
GpsUtil.decimalLatitudeToDDM(update.gpsCoordinates.latitude),
GpsUtil.decimalLongitudeToDDM(update.gpsCoordinates.longitude)
)
else
deleteGpsCoordinates()

/* Location */

val locationShown = update.locationShown

if (locationShown == null) {
setLocation(null)
return
}

setLocation(
XMPLocation(
name = locationShown.name,
location = locationShown.location,
city = locationShown.city,
state = locationShown.state,
country = locationShown.country
)
)
}

is MetadataUpdate.Title ->
setTitle(update.title)

Expand Down
59 changes: 51 additions & 8 deletions src/commonMain/kotlin/com/ashampoo/kim/model/GpsCoordinates.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,72 @@ private const val MIN_LATITUDE: Double = -MAX_LATITUDE
private const val MAX_LONGITUDE = 180.0
private const val MIN_LONGITUDE = -MAX_LONGITUDE

/** Around ~100 m accuracy */
private const val THREE_DIGIT_PRECISE: Double = 1_000.0

/** Around ~1 m accuracy */
private const val FIVE_DIGIT_PRECISE: Double = 100_000.0

private const val LAT_LONG_STRING_REGEX_PATTERN =
"""^\s*-?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*-?(180(\.0+)?|((1[0-7]\d)|(\d{1,2}))(\.\d+)?)\s*$"""

private val latLongStringRegex: Regex = LAT_LONG_STRING_REGEX_PATTERN.toRegex()

public data class GpsCoordinates(
val latitude: Double,
val longitude: Double
) {

val displayString: String = "GPS: ${roundForDisplay(latitude)}, ${roundForDisplay(longitude)}"
val latLongString: String = "${roundPrecise(latitude)}, ${roundPrecise(longitude)}"

public fun toRoundedForCaching(): GpsCoordinates = GpsCoordinates(
latitude = roundForCaching(latitude),
longitude = roundForCaching(longitude)
public fun toPreciseCoordinates(): GpsCoordinates = GpsCoordinates(
latitude = roundPrecise(latitude),
longitude = roundPrecise(longitude)
)

public fun isNullIsland(): Boolean = latitude == 0.0 && longitude == 0.0
public fun toCoarseCoordinates(): GpsCoordinates = GpsCoordinates(
latitude = roundCoarse(latitude),
longitude = roundCoarse(longitude)
)

public fun isNullIsland(): Boolean =
latitude == 0.0 && longitude == 0.0

public fun isValid(): Boolean =
latitude in MIN_LATITUDE..MAX_LATITUDE && longitude in MIN_LONGITUDE..MAX_LONGITUDE
latitude in MIN_LATITUDE..MAX_LATITUDE &&
longitude in MIN_LONGITUDE..MAX_LONGITUDE

public companion object {

public fun parse(latLongString: String?): GpsCoordinates? {

if (latLongString.isNullOrBlank())
return null

if (!latLongStringRegex.matches(latLongString))
return null

val parts = latLongString.split(",")

return GpsCoordinates(
latitude = parts[0].toDouble(),
longitude = parts[1].toDouble()
)
}
}
}

private fun roundForCaching(value: Double): Double =
/**
* Rounds the coordinates to three decimal places,
* providing approximately 100 meters accuracy.
*/
private fun roundCoarse(value: Double): Double =
round(value * THREE_DIGIT_PRECISE) / THREE_DIGIT_PRECISE

private fun roundForDisplay(value: Double): Double =
/**
* Rounds the coordinates to five decimal places,
* providing approximately 1 meter accuracy.
* Suitable for display and precise localization.
*/
private fun roundPrecise(value: Double): Double =
round(value * FIVE_DIGIT_PRECISE) / FIVE_DIGIT_PRECISE
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ public sealed interface MetadataUpdate {
val locationShown: com.ashampoo.kim.model.LocationShown?
) : MetadataUpdate

/*
* One-shot update GPS coordinates & location
*/
public data class GpsCoordinatesAndLocationShown(
val gpsCoordinates: com.ashampoo.kim.model.GpsCoordinates?,
val locationShown: com.ashampoo.kim.model.LocationShown?
) : MetadataUpdate

/**
* New title or NULL to remove it
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ public data class PhotoMetadata(
(widthPx * heightPx).div(PhotoValueFormatter.MEGA_PIXEL_COUNT)

val locationDisplay: String?
get() = locationShown?.displayString ?: gpsCoordinates?.let { gpsCoordinates.displayString }
get() = locationShown?.displayString
?: gpsCoordinates?.let { "GPS: " + gpsCoordinates.latLongString }

val cameraName: String?
get() = PhotoValueFormatter.createCameraOrLensName(cameraMake, cameraModel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,34 @@ abstract class AbstractUpdaterTest(
compare("new_location_shown.no_metadata.$format", newBytes)
}

@Test
fun testUpdateGpsCoordinatesAndLocationShown() {

val newBytes = Kim.update(
bytes = originalBytes,
update = MetadataUpdate.GpsCoordinatesAndLocationShown(
gpsCoordinates = crashBuildingGps,
locationShown = crashBuildingLocation
)
)

compare("new_gps_coordinates_and_location_shown.$format", newBytes)
}

@Test
fun testUpdateGpsCoordinatesAndLocationShownOnEmptyImage() {

val newBytes = Kim.update(
bytes = noMetadataBytes,
update = MetadataUpdate.GpsCoordinatesAndLocationShown(
gpsCoordinates = crashBuildingGps,
locationShown = crashBuildingLocation
)
)

compare("new_gps_coordinates_and_location_shown.no_metadata.$format", newBytes)
}

@Test
fun testUpdateTitle() {

Expand Down
Loading

0 comments on commit 13036dc

Please sign in to comment.