Skip to content

Commit

Permalink
Improved PNG support & introducing Kim.update() API (#14)
Browse files Browse the repository at this point in the history
- Improved PNG support (also reading non-standard EXIF & IPTC)
- New Kim.update() API
- Refactorings & additional unit tests
  • Loading branch information
StefanOltmann authored Jul 13, 2023
1 parent 73b0413 commit d8a25e3
Show file tree
Hide file tree
Showing 95 changed files with 1,993 additions and 782 deletions.
26 changes: 26 additions & 0 deletions .idea/runConfigurations/Build___Test.xml

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

25 changes: 25 additions & 0 deletions .idea/runConfigurations/Test.xml

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

12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,19 @@ It's part of [Ashampoo Photos](https://ashampoo.com/photos).

## Features

Current features:

* JPG: Read & Write EXIF, IPTC & XMP
* PNG: Read & Write EXIF Chunk & XMP (iTXT)
* PNG: Read & Write `eXIf` chunk & XMP. Also read non-standard EXIF & IPTC from `tEXt`/`zTXt` chunk.
* TIFF: Read EXIF & XMP
* Handling of XMP content through [XMP Core for Kotlin Multiplatform](https://github.com/Ashampoo/xmpcore).
* Convenicent `Kim.update()` API to perform updates to the relevant places.

The future development of features on our part is driven entirely by the
needs of Ashampoo Photos, which, in turn, is driven by user community feedback.

## Installation

```
implementation("com.ashampoo:kim:0.1.5")
implementation("com.ashampoo:kim:0.3.0")
```

## Sample usage in Kotlin (for JVM)
Expand Down Expand Up @@ -107,6 +106,8 @@ fun main() {
}
```

Also see `JpegUpdaterTest` & `PngUpdaterTest` to see how to perform updates.

## Sample usage in Java

This is the equivalent Java code as shown above.
Expand Down Expand Up @@ -187,9 +188,6 @@ public class Main {

## Limitations

We are actively working to address the following limitations in future updates:

* No support for EXIF & IPTC in PNG zTXT chunks.
* Inability to update EXIF, IPTC and XMP in JPG files simultaneously.
* Insufficient error handling for broken or non-standard conforming files.

Expand Down
2 changes: 1 addition & 1 deletion detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ style:
SpacingBetweenPackageAndImports:
active: true
ThrowsCount:
active: true
active: false
max: 2
excludeGuardClauses: false
TrailingWhitespace:
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.1.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
39 changes: 29 additions & 10 deletions src/commonMain/kotlin/com/ashampoo/kim/Kim.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,23 @@ import com.ashampoo.kim.common.ImageWriteException
import com.ashampoo.kim.format.ImageMetadata
import com.ashampoo.kim.format.ImageParser
import com.ashampoo.kim.format.jpeg.JpegMetadataExtractor
import com.ashampoo.kim.format.jpeg.JpegRewriter
import com.ashampoo.kim.format.jpeg.iptc.IptcMetadata
import com.ashampoo.kim.format.jpeg.iptc.IptcRecord
import com.ashampoo.kim.format.jpeg.iptc.IptcTypes
import com.ashampoo.kim.format.jpeg.JpegUpdater
import com.ashampoo.kim.format.png.PngMetadataExtractor
import com.ashampoo.kim.format.png.PngUpdater
import com.ashampoo.kim.format.raf.RafMetadataExtractor
import com.ashampoo.kim.input.ByteArrayByteReader
import com.ashampoo.kim.input.ByteReader
import com.ashampoo.kim.input.KtorInputByteReader
import com.ashampoo.kim.input.PrePendingByteReader
import com.ashampoo.kim.model.ImageFormat
import com.ashampoo.kim.model.MetadataUpdate
import com.ashampoo.kim.output.ByteArrayByteWriter
import com.ashampoo.kim.output.ByteWriter
import com.ashampoo.kim.xmp.XmpWriter
import com.ashampoo.xmp.XMPMeta
import com.ashampoo.xmp.XMPMetaFactory
import io.ktor.utils.io.core.Input
import io.ktor.utils.io.core.use

object Kim {

var underUnitTesting: Boolean = false

@kotlin.jvm.JvmStatic
@Throws(ImageReadException::class)
fun readMetadata(bytes: ByteArray): ImageMetadata? =
Expand All @@ -63,7 +58,7 @@ object Kim {
val imageParser = ImageParser.forFormat(imageFormat)

if (imageParser == null)
return ImageMetadata(imageFormat, null, null, null, null)
return ImageMetadata(imageFormat, null, null, null, null, null)

val newReader = PrePendingByteReader(it, headerBytes.toList())

Expand Down Expand Up @@ -94,4 +89,28 @@ object Kim {
else -> imageFormat to byteArrayOf()
}
}

/**
* Updates the file with the wanted updates.
*
* **Note**: We don't have an good API for single-shot write all fields right now.
* So this is inefficent at this time. This method is experimental and will likely change.
*/
fun update(
bytes: ByteArray,
updates: Set<MetadataUpdate>
): ByteArray {

if (updates.isEmpty())
return bytes

val imageFormat = ImageFormat.detect(bytes)

return when (imageFormat) {
ImageFormat.JPEG -> JpegUpdater.update(bytes, updates)
ImageFormat.PNG -> PngUpdater.update(bytes, updates)
null -> throw ImageWriteException("Unsupported/Undetected file format.")
else -> throw ImageWriteException("Can't embedd into $imageFormat")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@
package com.ashampoo.kim.common

private const val FF = 0xFF
private const val HEX_RADIX = 16

const val HEX_RADIX = 16

fun Byte.toHex(): String =
this.toInt().and(FF).toString(HEX_RADIX).padStart(2, '0')

fun convertHexStringToByteArray(string: String): ByteArray =
string
.chunked(2)
.map { it.toInt(HEX_RADIX).toByte() }
.toByteArray()

@Suppress("MagicNumber")
fun ByteArray.toHex(): String =
joinToString("") { it.toHex() }
Expand Down
14 changes: 0 additions & 14 deletions src/commonMain/kotlin/com/ashampoo/kim/common/ByteConversions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -260,20 +260,6 @@ fun ByteArray.toUInt16(offset: Int, byteOrder: ByteOrder): Int {
byte1 shl 8 or byte0
}

fun ByteArray.toUInt16s(byteOrder: ByteOrder): IntArray =
this.toUInt16s(0, size, byteOrder)

private fun ByteArray.toUInt16s(offset: Int, length: Int, byteOrder: ByteOrder): IntArray {

val result = IntArray(length / 2)

repeat(result.size) { i ->
result[i] = toUInt16(offset + 2 * i, byteOrder)
}

return result
}

fun ByteArray.toInt(byteOrder: ByteOrder): Int =
this.toInt(0, byteOrder)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2023 Ashampoo GmbH & Co. KG
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ashampoo.kim.common

import kotlinx.datetime.LocalDateTime

private const val YEAR_LENGTH = 4

fun LocalDateTime.toExifDateString(): String {

return year.toString().padStart(YEAR_LENGTH, '0') + ":" +
monthNumber.toString().padStart(2, '0') + ":" +
dayOfMonth.toString().padStart(2, '0') + " " +
hour.toString().padStart(2, '0') + ":" +
minute.toString().padStart(2, '0') + ":" +
second.toString().padStart(2, '0')
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,26 @@
*/
package com.ashampoo.kim.common

import com.ashampoo.kim.Kim.underUnitTesting
import com.ashampoo.kim.format.ImageMetadata
import com.ashampoo.kim.format.jpeg.iptc.IptcTypes
import com.ashampoo.kim.format.tiff.GPSInfo
import com.ashampoo.kim.format.tiff.constants.ExifTag
import com.ashampoo.kim.format.tiff.constants.TiffConstants
import com.ashampoo.kim.format.tiff.constants.TiffTag
import com.ashampoo.kim.format.xmp.XmpReader
import com.ashampoo.kim.model.GpsCoordinates
import com.ashampoo.kim.model.PhotoMetadata
import com.ashampoo.kim.model.TiffOrientation
import com.ashampoo.kim.xmp.XmpReader
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant

fun ImageMetadata.convertToPhotoMetadata(
underUnitTesting: Boolean = false
): PhotoMetadata {
fun ImageMetadata.convertToPhotoMetadata(): PhotoMetadata {

val orientation = TiffOrientation.of(findShortValue(TiffTag.TIFF_TAG_ORIENTATION)?.toInt())

val takenDateMillis = extractTakenDateMillis(this, underUnitTesting)
val takenDateMillis = extractTakenDateMillis(this)

val gpsDirectory = findTiffDirectory(TiffConstants.DIRECTORY_TYPE_GPS)

Expand Down Expand Up @@ -75,7 +74,7 @@ fun ImageMetadata.convertToPhotoMetadata(
null

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

/*
Expand Down Expand Up @@ -135,10 +134,7 @@ private fun extractTakenDateAsIso(metadata: ImageMetadata): String? {
return convertExifDateToIso8601Date(takenDate)
}

private fun extractTakenDateMillis(
metadata: ImageMetadata,
underUnitTesting: Boolean
): Long? {
private fun extractTakenDateMillis(metadata: ImageMetadata): Long? {

val exif = metadata.exif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ data class ImageMetadata(
val imageFormat: ImageFormat?,
val imageSize: ImageSize?,
val exif: TiffContents?,
val exifBytes: ByteArray?,
val iptc: IptcMetadata?,
val xmp: String?
) {
Expand Down
42 changes: 23 additions & 19 deletions src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegConstants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ object JpegConstants {
0x45, // E
0x78, // x
0x69, // i
0x66 // f
0x66, // f
0, // NUL
0 // NUL
)

const val EXIF_IDENTIFIER_CODE_HEX: String = "457869660000"

val XMP_IDENTIFIER = byteArrayOf(
0x68, // h
0x74, // t
Expand Down Expand Up @@ -83,7 +87,7 @@ object JpegConstants {

val SOI = byteArrayOf(0xFF.toByte(), 0xd8.toByte())

val EOI = byteArrayOf(0xFF.toByte(), 0xd9.toByte())
// val EOI = byteArrayOf(0xFF.toByte(), 0xd9.toByte())

const val JPEG_APP0 = 0xE0
const val JPEG_APP0_MARKER = 0xFF00 or JPEG_APP0
Expand Down Expand Up @@ -145,19 +149,19 @@ object JpegConstants {
JpegConstants.SOF15_MARKER
)

val MARKERS = listOf(
JPEG_APP0, JPEG_APP0_MARKER,
JPEG_APP1_MARKER, JPEG_APP2_MARKER, JPEG_APP13_MARKER,
JPEG_APP14_MARKER, JPEG_APP15_MARKER, JFIF_MARKER,
SOF0_MARKER, SOF1_MARKER, SOF2_MARKER, SOF3_MARKER, DHT_MARKER,
SOF5_MARKER, SOF6_MARKER, SOF7_MARKER, SOF8_MARKER, SOF9_MARKER,
SOF10_MARKER, SOF11_MARKER, DAC_MARKER, SOF13_MARKER,
SOF14_MARKER, SOF15_MARKER, EOI_MARKER, SOS_MARKER, DQT_MARKER,
DNL_MARKER, COM_MARKER, DRI_MARKER, RST0_MARKER, RST1_MARKER, RST2_MARKER,
RST3_MARKER, RST4_MARKER, RST5_MARKER, RST6_MARKER, RST7_MARKER
)

val PHOTOSHOP_IDENTIFICATION_STRING = byteArrayOf(
// val MARKERS = listOf(
// JPEG_APP0, JPEG_APP0_MARKER,
// JPEG_APP1_MARKER, JPEG_APP2_MARKER, JPEG_APP13_MARKER,
// JPEG_APP14_MARKER, JPEG_APP15_MARKER, JFIF_MARKER,
// SOF0_MARKER, SOF1_MARKER, SOF2_MARKER, SOF3_MARKER, DHT_MARKER,
// SOF5_MARKER, SOF6_MARKER, SOF7_MARKER, SOF8_MARKER, SOF9_MARKER,
// SOF10_MARKER, SOF11_MARKER, DAC_MARKER, SOF13_MARKER,
// SOF14_MARKER, SOF15_MARKER, EOI_MARKER, SOS_MARKER, DQT_MARKER,
// DNL_MARKER, COM_MARKER, DRI_MARKER, RST0_MARKER, RST1_MARKER, RST2_MARKER,
// RST3_MARKER, RST4_MARKER, RST5_MARKER, RST6_MARKER, RST7_MARKER
// )

val APP13_IDENTIFIER = byteArrayOf(
0x50, // P
0x68, // h
0x6F, // o
Expand All @@ -176,10 +180,10 @@ object JpegConstants {

const val IPTC_MAX_BLOCK_NAME_LENGTH: Int = 255

val CONST_8BIM = charsToQuad('8', 'B', 'I', 'M')
/** Int value of "8BIM" */
const val IPTC_RESOURCE_BLOCK_SIGNATURE_INT = 943_868_237

private fun charsToQuad(c1: Char, c2: Char, c3: Char, c4: Char): Int =
0xFF and c1.code shl 24 or (0xFF and c2.code shl 16) or
(0xFF and c3.code shl 8) or (0xFF and c4.code shl 0)
/** Hex value of "8BIM" (38 42 49 4D) */
const val IPTC_RESOURCE_BLOCK_SIGNATURE_HEX = "3842494d"

}
Loading

0 comments on commit d8a25e3

Please sign in to comment.