Skip to content

Commit

Permalink
KotlinX IO support & better Ktor support (#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
StefanOltmann authored Nov 9, 2023
1 parent bc8474a commit 2c2c417
Show file tree
Hide file tree
Showing 50 changed files with 817 additions and 505 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ of Ashampoo Photos, which, in turn, is driven by user community feedback.
## Installation

```
implementation("com.ashampoo:kim:0.5.5")
implementation("com.ashampoo:kim:0.6")
```

## Sample usages

### Read metadata

`Kim.readMetadata()` takes `kotlin.ByteArray` & `io.ktor.utils.io.core.ByteReadPacket`
on all platforms and depending on the platform also `java.io.File`,
`Kim.readMetadata()` takes `kotlin.ByteArray`, `kotlinx.io.files.Path`, Ktor `ByteReadPacket` &
Ktor `ByteReadChannel` on all platforms and depending on the platform also `java.io.File`,
`java.io.InputStream`, `NSData` and string paths.

```kotlin
Expand Down
10 changes: 5 additions & 5 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ repositories {

val productName = "Ashampoo Kim"

val ktorVersion: String = "2.3.5"
val xmpCoreVersion: String = "0.1.7"
val ktorVersion: String = "2.3.6"
val xmpCoreVersion: String = "0.2"
val dateTimeVersion: String = "0.4.1"
val testRessourcesVersion: String = "0.4.0"
val ioCoreVersion: String = "0.3.0"
Expand Down Expand Up @@ -167,6 +167,9 @@ kotlin {

/* XMP handling */
api("com.ashampoo:xmpcore:$xmpCoreVersion")

/* Multiplatform file access */
api("org.jetbrains.kotlinx:kotlinx-io-core:$ioCoreVersion")
}
}

Expand All @@ -179,9 +182,6 @@ kotlin {

/* Multiplatform test resources */
implementation("com.goncalossilva:resources:$testRessourcesVersion")

/* Multiplatform file access */
implementation("org.jetbrains.kotlinx:kotlinx-io-core:$ioCoreVersion")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ import java.io.InputStream
*/
@Throws(ImageReadException::class)
fun Kim.readMetadataAndroid(inputStream: InputStream, length: Long): ImageMetadata? =
Kim.readMetadata(AndroidInputStreamByteReader(inputStream), length)
Kim.readMetadata(AndroidInputStreamByteReader(inputStream, length))
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ package com.ashampoo.kim.input
import java.io.InputStream

open class AndroidInputStreamByteReader(
private val inputStream: InputStream
private val inputStream: InputStream,
override val contentLength: Long
) : ByteReader {

override fun readByte(): Byte? {
Expand Down
4 changes: 2 additions & 2 deletions src/appleMain/kotlin/com/ashampoo/kim/AppleKimExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ import platform.posix.memcpy

@Throws(ImageReadException::class)
fun Kim.readMetadata(data: NSData): ImageMetadata? =
Kim.readMetadata(ByteArrayByteReader(convertDataToByteArray(data)), data.length.toLong())
Kim.readMetadata(ByteArrayByteReader(convertDataToByteArray(data)))

@Throws(ImageReadException::class)
fun Kim.readMetadata(path: String): ImageMetadata? {

val fileBytes = readFileAsByteArray(path) ?: return null

return Kim.readMetadata(ByteArrayByteReader(fileBytes), fileBytes.size.toLong())
return Kim.readMetadata(ByteArrayByteReader(fileBytes))
}

@OptIn(ExperimentalForeignApi::class)
Expand Down
94 changes: 76 additions & 18 deletions src/commonMain/kotlin/com/ashampoo/kim/Kim.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,18 @@ import com.ashampoo.kim.format.tiff.TiffReader
import com.ashampoo.kim.input.ByteArrayByteReader
import com.ashampoo.kim.input.ByteReader
import com.ashampoo.kim.input.DefaultRandomAccessByteReader
import com.ashampoo.kim.input.KotlinIoSourceByteReader
import com.ashampoo.kim.input.KtorByteReadChannelByteReader
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 io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.core.ByteReadPacket
import io.ktor.utils.io.core.use
import kotlinx.io.files.Path

object Kim {

Expand All @@ -50,18 +56,35 @@ object Kim {
@kotlin.jvm.JvmStatic
@Throws(ImageReadException::class)
fun readMetadata(bytes: ByteArray): ImageMetadata? =
if (bytes.isEmpty()) null else readMetadata(ByteArrayByteReader(bytes), bytes.size.toLong())
if (bytes.isEmpty())
null
else
readMetadata(ByteArrayByteReader(bytes))

@OptIn(ExperimentalStdlibApi::class)
@kotlin.jvm.JvmStatic
@Throws(ImageReadException::class)
fun readMetadata(path: Path): ImageMetadata? = tryWithImageReadException {

KotlinIoSourceByteReader.read(path) { byteReader ->
byteReader?.let { readMetadata(it) }
}
}

@kotlin.jvm.JvmStatic
@Throws(ImageReadException::class)
fun readMetadata(byteReadPacket: ByteReadPacket): ImageMetadata? =
readMetadata(KtorInputByteReader(byteReadPacket), byteReadPacket.remaining)
readMetadata(KtorInputByteReader(byteReadPacket))

@kotlin.jvm.JvmStatic
@Throws(ImageReadException::class)
fun readMetadata(byteReadChannel: ByteReadChannel, contentLength: Long): ImageMetadata? =
readMetadata(KtorByteReadChannelByteReader(byteReadChannel, contentLength))

@kotlin.jvm.JvmStatic
@Throws(ImageReadException::class)
fun readMetadata(
byteReader: ByteReader,
length: Long
byteReader: ByteReader
): ImageMetadata? = tryWithImageReadException {

byteReader.use {
Expand All @@ -82,7 +105,7 @@ object Kim {
* "TIFF" for every TIFF-based RAW format like CR2.
*/
return@use imageParser
.parseMetadata(newReader, length)
.parseMetadata(newReader)
.copy(imageFormat = imageFormat)
}
}
Expand Down Expand Up @@ -118,8 +141,7 @@ object Kim {
@kotlin.jvm.JvmStatic
@Throws(ImageReadException::class)
fun extractPreviewImage(
byteReader: ByteReader,
length: Long
byteReader: ByteReader
): ByteArray? = tryWithImageReadException {

byteReader.use {
Expand All @@ -133,7 +155,7 @@ object Kim {
if (imageFormat == ImageFormat.RAF)
return@use RafPreviewExtractor.extractPreviewImage(prePendingByteReader)

val reader = DefaultRandomAccessByteReader(prePendingByteReader, length)
val reader = DefaultRandomAccessByteReader(prePendingByteReader)

val tiffContents = TiffReader.read(reader)

Expand All @@ -158,10 +180,13 @@ object Kim {
}

/**
* Updates the file with the wanted updates.
* Updates the file with the desired changes.
*
* **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.
* **Note**: This method is provided for convenience, but it's not recommended for
* very large image files that should not be entirely loaded into memory.
* Currently, the update logic reads the entire file, which may not be efficient
* for large files. Please be aware that this behavior is subject to change in
* future updates.
*/
@kotlin.jvm.JvmStatic
@Throws(ImageWriteException::class)
Expand All @@ -170,15 +195,48 @@ object Kim {
updates: Set<MetadataUpdate>
): ByteArray = tryWithImageWriteException {

/* If there is nothing to update we return the bytes as is. */
if (updates.isEmpty())
return bytes
/* Prevent accidental calls that have no effect other than unnecessary work. */
check(updates.isNotEmpty()) { "There are no updates to perform." }

val byteArrayByteWriter = ByteArrayByteWriter()

update(
byteReader = ByteArrayByteReader(bytes),
byteWriter = byteArrayByteWriter,
updates = updates
)

return@tryWithImageWriteException byteArrayByteWriter.toByteArray()
}

/**
* 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 as it reads the whole file in.
*
* But this already represents the planned future API for streaming updates.
*/
@kotlin.jvm.JvmStatic
@Throws(ImageWriteException::class)
fun update(
byteReader: ByteReader,
byteWriter: ByteWriter,
updates: Set<MetadataUpdate>
) = tryWithImageWriteException {

/* Prevent accidental calls that have no effect other than unnecessary work. */
check(updates.isNotEmpty()) { "There are no updates to perform." }

val headerBytes = byteReader.readBytes(ImageFormat.REQUIRED_HEADER_BYTE_COUNT_FOR_DETECTION)

val imageFormat = ImageFormat.detect(headerBytes)

val imageFormat = ImageFormat.detect(bytes)
val prePendingByteReader = PrePendingByteReader(byteReader, headerBytes.toList())

return@tryWithImageWriteException when (imageFormat) {
ImageFormat.JPEG -> JpegUpdater.update(bytes, updates)
ImageFormat.PNG -> PngUpdater.update(bytes, updates)
when (imageFormat) {
ImageFormat.JPEG -> JpegUpdater.update(prePendingByteReader, byteWriter, updates)
ImageFormat.PNG -> PngUpdater.update(prePendingByteReader, byteWriter, updates)
null -> throw ImageWriteException("Unsupported or unsupoorted file format.")
else -> throw ImageWriteException("Can't embed metadata into $imageFormat.")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readByteArray

@OptIn(ExperimentalStdlibApi::class)
fun Path.copyTo(destination: Path) {

require(exists()) { "$this does not exist." }

val metadata = SystemFileSystem.metadataOrNull(this)

requireNotNull(metadata) { "Failed to read metadata of $this" }
require(metadata.isRegularFile) { "Source $this must be a regular file." }

SystemFileSystem.source(this).buffered().use { rawSource ->
SystemFileSystem.sink(destination).buffered().use { sink ->
sink.write(rawSource, metadata.size)
}
}
}

@OptIn(ExperimentalStdlibApi::class)
fun Path.writeBytes(byteArray: ByteArray) =
SystemFileSystem
.sink(this)
.buffered()
.use { it.write(byteArray) }

@OptIn(ExperimentalStdlibApi::class)
fun Path.readBytes(): ByteArray =
SystemFileSystem
.source(this)
.buffered()
.use { it.readByteArray() }

@OptIn(ExperimentalStdlibApi::class)
fun Path.exists(): Boolean =
SystemFileSystem.exists(this)
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import com.ashampoo.kim.model.ImageFormat
fun interface ImageParser {

@Throws(ImageReadException::class)
fun parseMetadata(byteReader: ByteReader, length: Long): ImageMetadata
fun parseMetadata(byteReader: ByteReader): ImageMetadata

companion object {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@
package com.ashampoo.kim.format

import com.ashampoo.kim.common.ImageWriteException
import com.ashampoo.kim.input.ByteReader
import com.ashampoo.kim.model.MetadataUpdate
import com.ashampoo.kim.output.ByteWriter

fun interface MetadataUpdater {

@Throws(ImageWriteException::class)
fun update(bytes: ByteArray, updates: Set<MetadataUpdate>): ByteArray

fun update(
byteReader: ByteReader,
byteWriter: ByteWriter,
updates: Set<MetadataUpdate>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import com.ashampoo.kim.model.ImageSize
object JpegImageParser : ImageParser {

@Throws(ImageReadException::class)
override fun parseMetadata(byteReader: ByteReader, length: Long): ImageMetadata =
override fun parseMetadata(byteReader: ByteReader): ImageMetadata =
tryWithImageReadException {

val segments = readSegments(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
*/
package com.ashampoo.kim.format.jpeg

import com.ashampoo.kim.common.ByteOrder
import com.ashampoo.kim.common.ImageWriteException
import com.ashampoo.kim.common.getRemainingBytes
import com.ashampoo.kim.common.toBytes
Expand Down
21 changes: 15 additions & 6 deletions src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdater.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,32 @@ import com.ashampoo.kim.format.tiff.TiffContents
import com.ashampoo.kim.format.tiff.write.TiffOutputSet
import com.ashampoo.kim.format.xmp.XmpWriter
import com.ashampoo.kim.input.ByteArrayByteReader
import com.ashampoo.kim.input.ByteReader
import com.ashampoo.kim.model.ImageFormat
import com.ashampoo.kim.model.MetadataUpdate
import com.ashampoo.kim.model.TiffOrientation
import com.ashampoo.kim.output.ByteArrayByteWriter
import com.ashampoo.kim.output.ByteWriter
import com.ashampoo.xmp.XMPMeta
import com.ashampoo.xmp.XMPMetaFactory

internal object JpegUpdater : MetadataUpdater {

@Throws(ImageWriteException::class)
override fun update(
bytes: ByteArray,
byteReader: ByteReader,
byteWriter: ByteWriter,
updates: Set<MetadataUpdate>
): ByteArray = tryWithImageWriteException {
) = tryWithImageWriteException {

if (updates.isEmpty())
return bytes
/* Prevent accidental calls that have no effect other than unnecessary work. */
check(updates.isNotEmpty()) { "There are no updates to perform." }

/*
* TODO Avoid the read all bytes and stream instead.
* This will require the implementation of single-shot updates to all fields.
*/
val bytes = byteReader.readRemainingBytes()

val kimMetadata = Kim.readMetadata(bytes)

Expand All @@ -58,7 +67,7 @@ internal object JpegUpdater : MetadataUpdater {

val iptcUpdatedBytes = updateIptc(exifUpdatedBytes, kimMetadata.iptc, updates)

return@tryWithImageWriteException iptcUpdatedBytes
byteWriter.write(iptcUpdatedBytes)
}

private fun updateXmp(inputBytes: ByteArray, xmp: String?, updates: Set<MetadataUpdate>): ByteArray {
Expand Down Expand Up @@ -134,7 +143,7 @@ internal object JpegUpdater : MetadataUpdater {
private fun tryLosslessOrientationUpdate(
inputBytes: ByteArray,
tiffOrientation: TiffOrientation
) : Boolean {
): Boolean {

val byteReader = ByteArrayByteReader(inputBytes)

Expand Down
Loading

0 comments on commit 2c2c417

Please sign in to comment.