Skip to content

Commit

Permalink
JPG: Perform lossless orientation changes where possible (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
StefanOltmann authored Oct 26, 2023
1 parent 94ed9ab commit 16a89dd
Show file tree
Hide file tree
Showing 24 changed files with 373 additions and 121 deletions.
5 changes: 3 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-1.9.0-blue.svg?logo=kotlin)](httpw://kotlinlang.org)
[![Kotlin](https://img.shields.io/badge/kotlin-1.9.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)
![macOS](https://img.shields.io/badge/-macOS-gray.svg?style=flat)
Expand All @@ -24,14 +24,15 @@ It's part of [Ashampoo Photos](https://ashampoo.com/photos).
* Handling of XMP content through
[XMP Core for Kotlin Multiplatform](https://github.com/Ashampoo/xmpcore)
* Convenient `Kim.update()` API to perform updates to the relevant places
+ JPG: Lossless rotation by modifying only one byte (where present)

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.5.3")
implementation("com.ashampoo:kim:0.5.4")
```

## Sample usages
Expand Down
2 changes: 1 addition & 1 deletion src/commonMain/kotlin/com/ashampoo/kim/Kim.kt
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ object Kim {

val reader = DefaultRandomAccessByteReader(prePendingByteReader, length)

val tiffContents = TiffReader().read(reader)
val tiffContents = TiffReader.read(reader)

/**
* *Note:* Olympus ORF is currently unsupported because the preview offset
Expand Down
25 changes: 0 additions & 25 deletions src/commonMain/kotlin/com/ashampoo/kim/common/BinaryFileParser.kt

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ object JpegConstants {
)

val JFIF0_SIGNATURE_ALTERNATIVE = byteArrayOf(
0x4a, // J
0x4A, // J
0x46, // F
0x49, // I
0x46, // F
Expand Down Expand Up @@ -85,21 +85,18 @@ object JpegConstants {
0
)

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

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

const val JPEG_APP0 = 0xE0
const val JPEG_APP0_MARKER = 0xFF00 or JPEG_APP0
const val JPEG_APP1_MARKER = 0xFF00 or JPEG_APP0 + 1
const val JPEG_APP2_MARKER = 0xFF00 or JPEG_APP0 + 2
const val JPEG_APP13_MARKER = 0xFF00 or JPEG_APP0 + 13
const val JPEG_APP14_MARKER = 0xFF00 or JPEG_APP0 + 14
const val JPEG_APP15_MARKER = 0xFF00 or JPEG_APP0 + 15

const val JFIF_MARKER = 0xFFE0
const val DHT_MARKER = 0xFFC0 + 0x4
const val DAC_MARKER = 0xFFC0 + 0xc
const val DAC_MARKER = 0xFFC0 + 0xC

const val SOF0_MARKER = 0xFFC0
const val SOF1_MARKER = 0xFFC0 + 0x1
Expand All @@ -110,11 +107,11 @@ object JpegConstants {
const val SOF7_MARKER = 0xFFC0 + 0x7
const val SOF8_MARKER = 0xFFC0 + 0x8
const val SOF9_MARKER = 0xFFC0 + 0x9
const val SOF10_MARKER = 0xFFC0 + 0xa
const val SOF11_MARKER = 0xFFC0 + 0xb
const val SOF13_MARKER = 0xFFC0 + 0xd
const val SOF14_MARKER = 0xFFC0 + 0xe
const val SOF15_MARKER = 0xFFC0 + 0xf
const val SOF10_MARKER = 0xFFC0 + 0xA
const val SOF11_MARKER = 0xFFC0 + 0xB
const val SOF13_MARKER = 0xFFC0 + 0xD
const val SOF14_MARKER = 0xFFC0 + 0xE
const val SOF15_MARKER = 0xFFC0 + 0xF

// marker for restart intervals
const val DRI_MARKER = 0xFFdd
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ object JpegImageParser : ImageParser {

val exifByteReader = ByteArrayByteReader(bytes)

val contents = TiffReader().read(exifByteReader)
val contents = TiffReader.read(exifByteReader)

return contents
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* 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.format.jpeg

import com.ashampoo.kim.common.ByteOrder
import com.ashampoo.kim.common.ImageReadException
import com.ashampoo.kim.common.toSingleNumberHexes
import com.ashampoo.kim.common.tryWithImageReadException
import com.ashampoo.kim.format.ImageFormatMagicNumbers
import com.ashampoo.kim.format.jpeg.JpegConstants.JPEG_BYTE_ORDER
import com.ashampoo.kim.format.tiff.TiffReader
import com.ashampoo.kim.format.tiff.constants.TiffConstants.TIFF_ENTRY_LENGTH
import com.ashampoo.kim.format.tiff.constants.TiffConstants.TIFF_HEADER_SIZE
import com.ashampoo.kim.format.tiff.constants.TiffTag
import com.ashampoo.kim.input.ByteReader

/**
* This algorithm quickly identifies the EXIF orientation offset.
* If the file already has one no restructuring of the whole file is necessary.
*/
object JpegOrientationOffsetFinder {

const val SEGMENT_IDENTIFIER = 0xFF.toByte()
const val SEGMENT_START_OF_SCAN = 0xDA.toByte()
const val MARKER_END_OF_IMAGE = 0xD9.toByte()
const val APP1_MARKER = 0xE1.toByte()

@OptIn(ExperimentalStdlibApi::class)
@Throws(ImageReadException::class)
@Suppress("ComplexMethod")
fun findOrientationOffset(
byteReader: ByteReader
): Long? = tryWithImageReadException {

val magicNumberBytes = byteReader.readBytes(ImageFormatMagicNumbers.jpegShort.size).toList()

/* Ensure it's actually a JPEG. */
require(magicNumberBytes == ImageFormatMagicNumbers.jpegShort) {
"JPEG magic number mismatch: ${magicNumberBytes.toByteArray().toSingleNumberHexes()}"
}

var orientationOffset: Long? = null

var positionCounter: Long = ImageFormatMagicNumbers.jpegShort.size.toLong()

@Suppress("LoopWithTooManyJumpStatements")
do {

var segmentIdentifier = byteReader.readByte() ?: break
var segmentType = byteReader.readByte() ?: break

positionCounter += 2

/*
* Find the segment marker. Markers are zero or more 0xFF bytes, followed by
* a 0xFF and then a byte not equal to 0x00 or 0xFF.
*/
while (
segmentIdentifier != SEGMENT_IDENTIFIER ||
segmentType == SEGMENT_IDENTIFIER ||
segmentType.toInt() == 0
) {

segmentIdentifier = segmentType

val nextSegmentType = byteReader.readByte() ?: break

positionCounter++

segmentType = nextSegmentType
}

if (segmentType == SEGMENT_START_OF_SCAN || segmentType == MARKER_END_OF_IMAGE)
break

/* Note: Segment length includes size bytes */
val segmentLength =
byteReader.read2BytesAsInt("segmentLength", JPEG_BYTE_ORDER) - 2

positionCounter += 2

if (segmentLength <= 0)
throw ImageReadException("Illegal JPEG segment length: $segmentLength")

/* We are only looking for the EXIF segment. */
if (segmentType != APP1_MARKER) {

byteReader.skipBytes("skip segment", segmentLength.toLong())

positionCounter += segmentLength

continue
}

val exifIdentifierBytes = byteReader.readBytes("EXIF identifier", 6)

positionCounter += 6

/* Skip the APP1 XMP segment. */
if (!exifIdentifierBytes.contentEquals(JpegConstants.EXIF_IDENTIFIER_CODE)) {
byteReader.skipBytes("skip segment", segmentLength.toLong() - 6)
positionCounter += segmentLength - 6
continue
}

val tiffHeader = TiffReader.readTiffHeader(byteReader)

val exifByteOrder = tiffHeader.byteOrder

byteReader.skipBytes(
"skip bytes to first IFD",
tiffHeader.offsetToFirstIFD - TIFF_HEADER_SIZE
)

val entryCount = byteReader.read2BytesAsInt("entrycount", exifByteOrder)

positionCounter += tiffHeader.offsetToFirstIFD + 2

for (entryIndex in 0 until entryCount) {

val tag = byteReader.read2BytesAsInt("Entry $entryIndex: 'tag'", exifByteOrder)

if (tag == TiffTag.TIFF_TAG_ORIENTATION.tag) {

orientationOffset = positionCounter + 8

if (exifByteOrder == ByteOrder.BIG_ENDIAN)
orientationOffset++

return orientationOffset

} else {

byteReader.skipBytes("skip TIFF entry", TIFF_ENTRY_LENGTH - 2L)

positionCounter += TIFF_ENTRY_LENGTH
}
}

/*
* We are now past the EXIF segment.
* If we reach this point there is no orientation flag.
*/
return null

} while (true)

return null
}
}
16 changes: 6 additions & 10 deletions src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegRewriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/
package com.ashampoo.kim.format.jpeg

import com.ashampoo.kim.common.BinaryFileParser
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 All @@ -42,11 +42,7 @@ import io.ktor.utils.io.core.toByteArray
/**
* Interface for Exif write/update/remove functionality for Jpeg/JFIF images.
*/
object JpegRewriter : BinaryFileParser() {

init {
byteOrder = JPEG_BYTE_ORDER
}
object JpegRewriter {

private fun readSegments(byteReader: ByteReader): JFIFPieces {

Expand Down Expand Up @@ -153,13 +149,13 @@ object JpegRewriter : BinaryFileParser() {

if (newBytes != null) {

val markerBytes = JpegConstants.JPEG_APP1_MARKER.toShort().toBytes(byteOrder)
val markerBytes = JpegConstants.JPEG_APP1_MARKER.toShort().toBytes(JPEG_BYTE_ORDER)

if (newBytes.size > JpegConstants.MAX_SEGMENT_SIZE)
throw ImageWriteException("APP1 Segment is too long: " + newBytes.size)

val markerLength = newBytes.size + 2
val markerLengthBytes = markerLength.toShort().toBytes(byteOrder)
val markerLengthBytes = markerLength.toShort().toBytes(JPEG_BYTE_ORDER)
var index = 0

val firstSegment = newSegments[index] as JFIFPieceSegment
Expand Down Expand Up @@ -190,13 +186,13 @@ object JpegRewriter : BinaryFileParser() {
if (newBytes == null)
continue

val markerBytes = JpegConstants.JPEG_APP1_MARKER.toShort().toBytes(byteOrder)
val markerBytes = JpegConstants.JPEG_APP1_MARKER.toShort().toBytes(JPEG_BYTE_ORDER)

if (newBytes.size > JpegConstants.MAX_SEGMENT_SIZE)
throw ImageWriteException("APP1 Segment is too long: " + newBytes.size)

val markerLength = newBytes.size + 2
val markerLengthBytes = markerLength.toShort().toBytes(byteOrder)
val markerLengthBytes = markerLength.toShort().toBytes(JPEG_BYTE_ORDER)

byteWriter.write(markerBytes)
byteWriter.write(markerLengthBytes)
Expand Down
37 changes: 37 additions & 0 deletions src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdater.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.ashampoo.kim.format.xmp.XmpWriter
import com.ashampoo.kim.input.ByteArrayByteReader
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.xmp.XMPMeta
import com.ashampoo.xmp.XMPMetaFactory
Expand Down Expand Up @@ -98,6 +99,23 @@ internal object JpegUpdater : MetadataUpdater {
if (exifUpdates.isEmpty())
return inputBytes

/*
* Verify if it's possible to perform a lossless update by making byte modifications.
* For orientation changes, it's feasible to achieve this with a single byte swap.
*/
if (exifUpdates.size == 1) {

val onlyUpdate = exifUpdates.first()

if (onlyUpdate is MetadataUpdate.Orientation) {

val updated = tryLosslessOrientationUpdate(inputBytes, onlyUpdate.tiffOrientation)

if (updated)
return inputBytes
}
}

val outputSet = exif?.createOutputSet() ?: TiffOutputSet()

outputSet.applyUpdates(exifUpdates)
Expand All @@ -113,6 +131,25 @@ internal object JpegUpdater : MetadataUpdater {
return byteWriter.toByteArray()
}

private fun tryLosslessOrientationUpdate(
inputBytes: ByteArray,
tiffOrientation: TiffOrientation
) : Boolean {

val byteReader = ByteArrayByteReader(inputBytes)

val orientationOffset = JpegOrientationOffsetFinder.findOrientationOffset(byteReader)

if (orientationOffset != null) {

inputBytes[orientationOffset.toInt()] = tiffOrientation.value.toByte()

return true
}

return false
}

private fun updateIptc(
inputBytes: ByteArray,
iptc: IptcMetadata?,
Expand Down
Loading

0 comments on commit 16a89dd

Please sign in to comment.