Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Read & write location shown, title & description #110

Merged
merged 13 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
chmod +x ./gradlew
./gradlew build test koverXmlReport detekt sonar assembleXCFramework -x jsBrowserTest -x jsNodeTest -x wasmJsBrowserTest -x wasmJsNodeTest --parallel
./gradlew build test koverXmlReport detekt sonar assembleXCFramework --parallel
- name: Set RELEASE_VERSION variable
run: |
echo "RELEASE_VERSION=$(cat build/version.txt)" >> $GITHUB_ENV
Expand Down
4 changes: 2 additions & 2 deletions .idea/runConfigurations/Build___Test.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 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.0.21-blue.svg?logo=kotlin)](httpw://kotlinlang.org)
[![Kotlin](https://img.shields.io/badge/kotlin-2.1.0-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.20.2")
implementation("com.ashampoo:kim:0.21.0")
```

For the targets `wasmJs` & `js` you also need to specify this:
Expand Down Expand Up @@ -80,12 +80,14 @@ It contains the following:

- Image size
- Orientation
- Taken date
- Date taken
- GPS coordinates
- Camera make & model
- Lens make & model
- ISO, Exposure time, F-Number, Focal length
- Image title & description
- Rating
- `XMP:pick` flag
- Keywords
- Faces (XMP-mwg-rs regions, used by Picasa and others)
- Persons in image
Expand Down
35 changes: 8 additions & 27 deletions 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.0.21" // Kotlin 2.1.0 results in compile errors!
kotlin("multiplatform") version "2.1.0"
id("com.android.library") version "8.5.0"
id("maven-publish")
id("signing")
Expand All @@ -14,7 +14,7 @@ plugins {
id("org.jetbrains.kotlinx.kover") version "0.6.1"
id("com.asarkar.gradle.build-time-tracker") version "4.3.0"
id("me.qoomon.git-versioning") version "6.4.4"
id("com.goncalossilva.resources") version "0.9.0" // 0.10.0 requires Kotlin 2.1.0
id("com.goncalossilva.resources") version "0.10.0"
id("com.github.ben-manes.versions") version "0.51.0"
id("org.jetbrains.dokka") version "1.9.20"
}
Expand All @@ -27,7 +27,7 @@ repositories {
val productName: String = "Ashampoo Kim"

val ktorVersion: String = "3.0.3"
val xmpCoreVersion: String = "1.4.2"
val xmpCoreVersion: String = "1.5.0"
val dateTimeVersion: String = "0.6.1"
val kotlinxIoVersion: String = "0.6.0"

Expand Down Expand Up @@ -170,32 +170,10 @@ kotlin {
}
}

js(IR) {

moduleName = "kim"

browser {
webpackTask {
mainOutputFileName = "kim.js"
output.library = "kimLib"
}
}

nodejs()

binaries.executable()
}
js()

@OptIn(ExperimentalWasmDsl::class)
wasmJs {

moduleName = "kim-wasm"

browser()
nodejs()

binaries.executable()
}
wasmJs()

// WASI support is planned for kotlinx-datetime v0.7
// @OptIn(ExperimentalWasmDsl::class)
Expand Down Expand Up @@ -373,6 +351,9 @@ kotlin {
// wasmWasiMain.dependsOn(this)

dependencies {

implementation("org.jetbrains.kotlinx:kotlinx-browser:0.3")

implementation(npm("pako", "2.1.0"))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.ashampoo.kim.format.tiff.constant.TiffTag
import com.ashampoo.kim.format.xmp.XmpReader
import com.ashampoo.kim.input.ByteArrayByteReader
import com.ashampoo.kim.model.GpsCoordinates
import com.ashampoo.kim.model.LocationShown
import com.ashampoo.kim.model.PhotoMetadata
import com.ashampoo.kim.model.TiffOrientation
import kotlinx.datetime.LocalDateTime
Expand All @@ -48,19 +49,20 @@ public object PhotoMetadataConverter {
ignoreOrientation: Boolean = false
): PhotoMetadata {

val xmpMetadata: PhotoMetadata? = imageMetadata.xmp?.let {
XmpReader.readMetadata(it)
}

val orientation = if (ignoreOrientation)
TiffOrientation.STANDARD
else
TiffOrientation.of(imageMetadata.findShortValue(TiffTag.TIFF_TAG_ORIENTATION)?.toInt())

val takenDateMillis = extractTakenDateMillis(imageMetadata)

val gpsDirectory = imageMetadata.findTiffDirectory(TiffConstants.TIFF_DIRECTORY_GPS)
val takenDateMillis: Long? = xmpMetadata?.takenDate
?: extractTakenDateMillisFromExif(imageMetadata)

val gps = gpsDirectory?.let(GPSInfo::createFrom)

val latitude = gps?.getLatitudeAsDegreesNorth()
val longitude = gps?.getLongitudeAsDegreesEast()
val gpsCoordinates: GpsCoordinates? = xmpMetadata?.gpsCoordinates
?: extractGpsCoordinatesFromExif(imageMetadata)

val cameraMake = imageMetadata.findStringValue(TiffTag.TIFF_TAG_MAKE)
val cameraModel = imageMetadata.findStringValue(TiffTag.TIFF_TAG_MODEL)
Expand All @@ -76,28 +78,22 @@ public object PhotoMetadataConverter {
val fNumber = imageMetadata.findDoubleValue(ExifTag.EXIF_TAG_FNUMBER)
val focalLength = imageMetadata.findDoubleValue(ExifTag.EXIF_TAG_FOCAL_LENGTH)

val keywords = mutableSetOf<String>()
val keywords = xmpMetadata?.keywords?.ifEmpty {
extractKeywordsFromIptc(imageMetadata)
} ?: extractKeywordsFromIptc(imageMetadata)

val iptcRecords = imageMetadata.iptc?.records

iptcRecords?.forEach {
val title = xmpMetadata?.title ?: iptcRecords
?.find { it.iptcType == IptcTypes.OBJECT_NAME }
?.value

if (it.iptcType == IptcTypes.KEYWORDS)
keywords.add(it.value)
}
val description = xmpMetadata?.description ?: iptcRecords
?.find { it.iptcType == IptcTypes.CAPTION_ABSTRACT }
?.value

val gpsCoordinates =
if (latitude != null && longitude != null)
GpsCoordinates(
latitude = latitude,
longitude = longitude
)
else
null

val xmpMetadata: PhotoMetadata? = imageMetadata.xmp?.let {
XmpReader.readMetadata(it)
}
val location = xmpMetadata?.locationShown
?: extractLocationFromIptc(imageMetadata)

val thumbnailBytes = imageMetadata.getExifThumbnailBytes()

Expand All @@ -120,9 +116,9 @@ public object PhotoMetadataConverter {
widthPx = imageMetadata.imageSize?.width,
heightPx = imageMetadata.imageSize?.height,
orientation = orientation,
takenDate = xmpMetadata?.takenDate ?: takenDateMillis,
gpsCoordinates = xmpMetadata?.gpsCoordinates ?: gpsCoordinates,
location = xmpMetadata?.location,
takenDate = takenDateMillis,
gpsCoordinates = gpsCoordinates,
locationShown = location,
cameraMake = cameraMake,
cameraModel = cameraModel,
lensMake = lensMake,
Expand All @@ -131,9 +127,11 @@ public object PhotoMetadataConverter {
exposureTime = exposureTime,
fNumber = fNumber,
focalLength = focalLength,
title = title,
description = description,
flagged = xmpMetadata?.flagged ?: false,
rating = xmpMetadata?.rating,
keywords = keywords.ifEmpty { xmpMetadata?.keywords ?: emptySet() },
keywords = keywords,
faces = xmpMetadata?.faces ?: emptyMap(),
personsInImage = xmpMetadata?.personsInImage ?: emptySet(),
albums = xmpMetadata?.albums ?: emptySet(),
Expand All @@ -143,7 +141,7 @@ public object PhotoMetadataConverter {
}

@JvmStatic
public fun extractTakenDateAsIsoString(metadata: ImageMetadata): String? {
private fun extractTakenDateAsIsoString(metadata: ImageMetadata): String? {

val takenDateField = metadata.findTiffField(ExifTag.EXIF_TAG_DATE_TIME_ORIGINAL)
?: return null
Expand All @@ -163,7 +161,9 @@ public object PhotoMetadataConverter {
}

@JvmStatic
public fun extractTakenDateMillis(metadata: ImageMetadata): Long? {
private fun extractTakenDateMillisFromExif(
metadata: ImageMetadata
): Long? {

try {

Expand Down Expand Up @@ -203,6 +203,71 @@ public object PhotoMetadataConverter {
}
}

@JvmStatic
private fun extractGpsCoordinatesFromExif(
metadata: ImageMetadata
): GpsCoordinates? {

val gpsDirectory = metadata.findTiffDirectory(TiffConstants.TIFF_DIRECTORY_GPS)

val gps = gpsDirectory?.let(GPSInfo::createFrom)

val latitude = gps?.getLatitudeAsDegreesNorth()
val longitude = gps?.getLongitudeAsDegreesEast()

if (latitude == null || longitude == null)
return null

return GpsCoordinates(
latitude = latitude,
longitude = longitude
)
}

@JvmStatic
private fun extractKeywordsFromIptc(
metadata: ImageMetadata
): Set<String> {

return metadata.iptc?.records
?.filter { it.iptcType == IptcTypes.KEYWORDS }
?.map { it.value }
?.toSet()
?: emptySet()
}

@JvmStatic
private fun extractLocationFromIptc(
metadata: ImageMetadata
): LocationShown? {

val iptcRecords = metadata.iptc?.records
?: return null

val iptcCity = iptcRecords
.find { it.iptcType == IptcTypes.CITY }
?.value

val iptcState = iptcRecords
.find { it.iptcType == IptcTypes.PROVINCE_STATE }
?.value

val iptcCountry = iptcRecords
.find { it.iptcType == IptcTypes.COUNTRY_PRIMARY_LOCATION_NAME }
?.value

/* Don't create an object if everything is NULL */
if (iptcCity.isNullOrBlank() && iptcState.isNullOrBlank() && iptcCountry.isNullOrBlank())
return null

return LocationShown(
name = null,
location = null,
city = iptcCity,
state = iptcState,
country = iptcCountry
)
}
}

public fun ImageMetadata.convertToPhotoMetadata(
Expand Down
Loading