diff --git a/README.md b/README.md index c2f27af0..908b6bc7 100644 --- a/README.md +++ b/README.md @@ -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) @@ -24,6 +24,7 @@ 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. @@ -31,7 +32,7 @@ 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 diff --git a/src/commonMain/kotlin/com/ashampoo/kim/Kim.kt b/src/commonMain/kotlin/com/ashampoo/kim/Kim.kt index 386f5e6b..a1a54685 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/Kim.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/Kim.kt @@ -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 diff --git a/src/commonMain/kotlin/com/ashampoo/kim/common/BinaryFileParser.kt b/src/commonMain/kotlin/com/ashampoo/kim/common/BinaryFileParser.kt deleted file mode 100644 index a0dd0371..00000000 --- a/src/commonMain/kotlin/com/ashampoo/kim/common/BinaryFileParser.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2023 Ashampoo GmbH & Co. KG - * Copyright 2007-2023 The Apache Software Foundation - * - * 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 - -open class BinaryFileParser { - - /* Big endian is the most common byte order. */ - var byteOrder: ByteOrder = ByteOrder.BIG_ENDIAN - protected set - -} diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegConstants.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegConstants.kt index 2381c624..277006d9 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegConstants.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegConstants.kt @@ -35,7 +35,7 @@ object JpegConstants { ) val JFIF0_SIGNATURE_ALTERNATIVE = byteArrayOf( - 0x4a, // J + 0x4A, // J 0x46, // F 0x49, // I 0x46, // F @@ -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 @@ -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 diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegImageParser.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegImageParser.kt index 0785828e..e25e8352 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegImageParser.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegImageParser.kt @@ -89,7 +89,7 @@ object JpegImageParser : ImageParser { val exifByteReader = ByteArrayByteReader(bytes) - val contents = TiffReader().read(exifByteReader) + val contents = TiffReader.read(exifByteReader) return contents } diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegOrientationOffsetFinder.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegOrientationOffsetFinder.kt new file mode 100644 index 00000000..5a995ce4 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegOrientationOffsetFinder.kt @@ -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 + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegRewriter.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegRewriter.kt index 95630093..bb889374 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegRewriter.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegRewriter.kt @@ -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 @@ -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 { @@ -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 @@ -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) diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdater.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdater.kt index 25f933b4..9715b1bd 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdater.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdater.kt @@ -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 @@ -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) @@ -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?, diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUtils.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUtils.kt index 07c63fa9..9b1d831c 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUtils.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/JpegUtils.kt @@ -16,17 +16,12 @@ */ package com.ashampoo.kim.format.jpeg -import com.ashampoo.kim.common.BinaryFileParser import com.ashampoo.kim.common.ImageReadException import com.ashampoo.kim.common.toUInt16 import com.ashampoo.kim.format.jpeg.JpegConstants.JPEG_BYTE_ORDER import com.ashampoo.kim.input.ByteReader -object JpegUtils : BinaryFileParser() { - - init { - byteOrder = JPEG_BYTE_ORDER - } +object JpegUtils { private fun findNextMarkerBytes(byteReader: ByteReader): ByteArray { @@ -51,7 +46,7 @@ object JpegUtils : BinaryFileParser() { val markerBytes = findNextMarkerBytes(byteReader) - val marker = markerBytes.toUInt16(byteOrder) + val marker = markerBytes.toUInt16(JPEG_BYTE_ORDER) if (marker == JpegConstants.EOI_MARKER || marker == JpegConstants.SOS_MARKER) { @@ -67,7 +62,7 @@ object JpegUtils : BinaryFileParser() { val segmentLengthBytes = byteReader.readBytes("segmentLengthBytes", 2) - val segmentLength = segmentLengthBytes.toUInt16(byteOrder) + val segmentLength = segmentLengthBytes.toUInt16(JPEG_BYTE_ORDER) if (segmentLength < 2) throw ImageReadException("Invalid segment size: $segmentLength") diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/iptc/IptcParser.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/iptc/IptcParser.kt index 7b6f07af..c3ce1489 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/iptc/IptcParser.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/iptc/IptcParser.kt @@ -16,7 +16,6 @@ */ package com.ashampoo.kim.format.jpeg.iptc -import com.ashampoo.kim.common.BinaryFileParser import com.ashampoo.kim.common.ByteOrder import com.ashampoo.kim.common.ImageReadException import com.ashampoo.kim.common.slice @@ -31,7 +30,7 @@ import io.ktor.utils.io.charsets.Charset import io.ktor.utils.io.charsets.Charsets import io.ktor.utils.io.core.String -object IptcParser : BinaryFileParser() { +object IptcParser { val EMPTY_BYTE_ARRAY = byteArrayOf() @@ -54,10 +53,6 @@ object IptcParser : BinaryFileParser() { val APP13_BYTE_ORDER = ByteOrder.BIG_ENDIAN - init { - byteOrder = APP13_BYTE_ORDER - } - /** * Checks if the ByteArray starts with the Photoshop identifaction header. * This is mandatory for IPTC embedded into APP13. @@ -120,7 +115,7 @@ object IptcParser : BinaryFileParser() { val recordNumber = bytes[index++].toUInt8() val recordType = bytes[index++].toUInt8() - val recordSize = bytes.toUInt16(index, byteOrder) + val recordSize = bytes.toUInt16(index, APP13_BYTE_ORDER) index += 2 val extendedDataset = recordSize > IptcConstants.IPTC_NON_EXTENDED_RECORD_MAXIMUM_SIZE diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/segments/Segment.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/segments/Segment.kt index d2e04631..c6b405b6 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/segments/Segment.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/segments/Segment.kt @@ -16,9 +16,13 @@ */ package com.ashampoo.kim.format.jpeg.segments -import com.ashampoo.kim.common.BinaryFileParser +import com.ashampoo.kim.common.ByteOrder -abstract class Segment(val marker: Int, val length: Int) : BinaryFileParser() { +abstract class Segment(val marker: Int, val length: Int) { + + /* Big endian is the most common byte order. */ + var byteOrder: ByteOrder = ByteOrder.BIG_ENDIAN + protected set abstract fun getDescription(): String diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/xmp/JpegXmpParser.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/xmp/JpegXmpParser.kt index 8eef5157..ccb2568a 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/xmp/JpegXmpParser.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/jpeg/xmp/JpegXmpParser.kt @@ -16,7 +16,6 @@ */ package com.ashampoo.kim.format.jpeg.xmp -import com.ashampoo.kim.common.BinaryFileParser import com.ashampoo.kim.common.ImageReadException import com.ashampoo.kim.common.startsWith import com.ashampoo.kim.format.jpeg.JpegConstants @@ -24,11 +23,7 @@ import com.ashampoo.kim.format.jpeg.JpegConstants.JPEG_BYTE_ORDER import io.ktor.utils.io.charsets.Charsets import io.ktor.utils.io.core.String -object JpegXmpParser : BinaryFileParser() { - - init { - byteOrder = JPEG_BYTE_ORDER - } +object JpegXmpParser { fun isXmpJpegSegment(segmentData: ByteArray): Boolean = segmentData.startsWith(JpegConstants.XMP_IDENTIFIER) diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/png/PngConstants.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/png/PngConstants.kt index 3b933385..56ce5516 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/png/PngConstants.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/png/PngConstants.kt @@ -16,8 +16,12 @@ */ package com.ashampoo.kim.format.png +import com.ashampoo.kim.common.ByteOrder + object PngConstants { + val PNG_BYTE_ORDER = ByteOrder.BIG_ENDIAN + /* ChunkType must be always 4 bytes */ const val TPYE_LENGTH = 4 diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/png/PngImageParser.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/png/PngImageParser.kt index ff926595..9fee194d 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/png/PngImageParser.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/png/PngImageParser.kt @@ -92,7 +92,7 @@ object PngImageParser : ImageParser { val exifChunk = chunks.find { it.chunkType == ChunkType.EXIF } ?: return null - return exifChunk.bytes to TiffReader().read(ByteArrayByteReader(exifChunk.bytes)) + return exifChunk.bytes to TiffReader.read(ByteArrayByteReader(exifChunk.bytes)) } /* @@ -146,7 +146,7 @@ object PngImageParser : ImageParser { * This should be fine now to be fed into the TIFF reader. */ return exifBytesWithoutIdentifier to - TiffReader().read(ByteArrayByteReader(exifBytesWithoutIdentifier)) + TiffReader.read(ByteArrayByteReader(exifBytesWithoutIdentifier)) } private fun getIptcFromTextChunk(chunks: List): IptcMetadata? { diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/png/chunks/PngChunk.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/png/chunks/PngChunk.kt index 779be8c3..ff424ad0 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/png/chunks/PngChunk.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/png/chunks/PngChunk.kt @@ -16,7 +16,6 @@ */ package com.ashampoo.kim.format.png.chunks -import com.ashampoo.kim.common.BinaryFileParser import com.ashampoo.kim.format.png.ChunkType open class PngChunk( @@ -24,7 +23,7 @@ open class PngChunk( val chunkType: ChunkType, val crc: Int, val bytes: ByteArray -) : BinaryFileParser() { +) { val ancillary: Boolean val isPrivate: Boolean diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/png/chunks/PngChunkIhdr.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/png/chunks/PngChunkIhdr.kt index 7e32ab2c..3105cd75 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/png/chunks/PngChunkIhdr.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/png/chunks/PngChunkIhdr.kt @@ -17,6 +17,7 @@ package com.ashampoo.kim.format.png.chunks import com.ashampoo.kim.format.png.ChunkType +import com.ashampoo.kim.format.png.PngConstants.PNG_BYTE_ORDER import com.ashampoo.kim.input.ByteArrayByteReader class PngChunkIhdr( @@ -32,7 +33,7 @@ class PngChunkIhdr( init { val byteReader = ByteArrayByteReader(bytes) - width = byteReader.read4BytesAsInt("width", byteOrder) - height = byteReader.read4BytesAsInt("height", byteOrder) + width = byteReader.read4BytesAsInt("width", PNG_BYTE_ORDER) + height = byteReader.read4BytesAsInt("height", PNG_BYTE_ORDER) } } diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/TiffImageParser.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/TiffImageParser.kt index 0fa770ea..07e3e7a4 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/TiffImageParser.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/TiffImageParser.kt @@ -36,7 +36,7 @@ object TiffImageParser : ImageParser { val randomAccessByteReader = DefaultRandomAccessByteReader(byteReader, length) - val exif = TiffReader().read(randomAccessByteReader) + val exif = TiffReader.read(randomAccessByteReader) val imageSize = getImageSize(exif) val xmp = getXmpXml(exif) diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/TiffReader.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/TiffReader.kt index 7f8a2fde..f72309a4 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/TiffReader.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/TiffReader.kt @@ -16,7 +16,6 @@ */ package com.ashampoo.kim.format.tiff -import com.ashampoo.kim.common.BinaryFileParser import com.ashampoo.kim.common.ByteOrder import com.ashampoo.kim.common.ImageReadException import com.ashampoo.kim.common.toInt @@ -34,7 +33,7 @@ import com.ashampoo.kim.format.tiff.taginfos.TagInfoLongs import com.ashampoo.kim.input.ByteReader import com.ashampoo.kim.input.RandomAccessByteReader -class TiffReader : BinaryFileParser() { +object TiffReader { private val offsetFields = listOf( ExifTag.EXIF_TAG_EXIF_OFFSET, @@ -50,25 +49,18 @@ class TiffReader : BinaryFileParser() { ExifTag.EXIF_TAG_SUB_IFDS_OFFSET to TiffConstants.DIRECTORY_TYPE_SUB ) - fun getTiffByteOrder(byteOrderByte: Int): ByteOrder = - when (byteOrderByte) { - 'I'.code -> ByteOrder.LITTLE_ENDIAN - 'M'.code -> ByteOrder.BIG_ENDIAN - else -> throw ImageReadException("Invalid TIFF byte order ${byteOrderByte.toUInt()}") - } - fun read(byteReader: RandomAccessByteReader): TiffContents { - val collector = TiffReaderCollector() - val tiffHeader = readTiffHeader(byteReader) - collector.tiffHeader = tiffHeader - byteReader.reset() + val collector = TiffReaderCollector() + collector.tiffHeader = tiffHeader + readDirectory( byteReader = byteReader, + byteOrder = tiffHeader.byteOrder, directoryOffset = tiffHeader.offsetToFirstIFD, dirType = TiffConstants.DIRECTORY_TYPE_ROOT, collector = collector, @@ -83,7 +75,7 @@ class TiffReader : BinaryFileParser() { return contents } - private fun readTiffHeader(byteReader: ByteReader): TiffHeader { + fun readTiffHeader(byteReader: ByteReader): TiffHeader { val byteOrder1 = byteReader.readByte("Byte order: First byte").toInt() val byteOrder2 = byteReader.readByte("Byte Order: Second byte").toInt() @@ -91,26 +83,33 @@ class TiffReader : BinaryFileParser() { if (byteOrder1 != byteOrder2) throw ImageReadException("Byte Order bytes don't match ($byteOrder1, $byteOrder2).") - byteOrder = getTiffByteOrder(byteOrder1) + val byteOrder = getTiffByteOrder(byteOrder1) val tiffVersion = byteReader.read2BytesAsInt("TIFF version", byteOrder) val offsetToFirstIFD = 0xFFFFFFFFL and byteReader.read4BytesAsInt("Offset to first IFD", byteOrder).toLong() - byteReader.skipBytes("skip bytes to first IFD", offsetToFirstIFD - 8) - return TiffHeader(byteOrder, tiffVersion, offsetToFirstIFD) } + private fun getTiffByteOrder(byteOrderByte: Int): ByteOrder = + when (byteOrderByte) { + 'I'.code -> ByteOrder.LITTLE_ENDIAN + 'M'.code -> ByteOrder.BIG_ENDIAN + else -> throw ImageReadException("Invalid TIFF byte order ${byteOrderByte.toUInt()}") + } + private fun readDirectory( byteReader: RandomAccessByteReader, + byteOrder: ByteOrder, directoryOffset: Long, dirType: Int, collector: TiffReaderCollector, visitedOffsets: MutableList ): Boolean { + /* We don't want to visit a directory twice. */ if (visitedOffsets.contains(directoryOffset)) return false @@ -118,22 +117,24 @@ class TiffReader : BinaryFileParser() { byteReader.reset() + /* + * Sometimes TIFF offsets are greater than the file itself. + * We ignore such corruptions. + */ if (directoryOffset >= byteReader.getLength()) return true byteReader.skipBytes("Directory offset", directoryOffset) - val fields = mutableListOf() - - val entryCount: Int - - entryCount = try { + val entryCount = try { byteReader.read2BytesAsInt("entrycount", byteOrder) } catch (ignore: ImageReadException) { return true } - repeat(entryCount) { entryIndex -> + val fields = mutableListOf() + + for (entryIndex in 0 until entryCount) { val tag = byteReader.read2BytesAsInt("Entry $entryIndex: 'tag'", byteOrder) val type = byteReader.read2BytesAsInt("Entry $entryIndex: 'type'", byteOrder) @@ -142,9 +143,15 @@ class TiffReader : BinaryFileParser() { 0xFFFFFFFFL and byteReader.read4BytesAsInt("Entry $entryIndex: 'count'", byteOrder).toLong() - val offsetBytes = byteReader.readBytes("Entry $entryIndex: 'offset'", 4) + /* + * These bytes represent either the value for fields like orientation or + * an offset to the value for fields like OriginalDateTime that + * cannot be accommodated within 4 bytes. + */ + val valueOrOffsetBytes: ByteArray = + byteReader.readBytes("Entry $entryIndex: 'offset'", 4) - val offset = 0xFFFFFFFFL and offsetBytes.toInt(byteOrder).toLong() + val valueOrOffset: Long = 0xFFFFFFFFL and valueOrOffsetBytes.toInt(byteOrder).toLong() /* * Skip invalid fields. @@ -152,7 +159,7 @@ class TiffReader : BinaryFileParser() { * which can cause OOM problems. */ if (tag == 0) - return@repeat + continue val fieldType: FieldType = try { getFieldType(type) @@ -161,7 +168,7 @@ class TiffReader : BinaryFileParser() { * Skip over unknown field types, since we can't calculate * their size without knowing their type */ - return@repeat + continue } val valueLength = count * fieldType.size @@ -169,17 +176,17 @@ class TiffReader : BinaryFileParser() { val valueBytes: ByteArray = if (valueLength > TIFF_ENTRY_MAX_VALUE_LENGTH) { /* Ignore corrupt offsets */ - if (offset < 0 || offset + valueLength > byteReader.getLength()) - return@repeat + if (valueOrOffset < 0 || valueOrOffset + valueLength > byteReader.getLength()) + continue - byteReader.readBytes(offset.toInt(), valueLength.toInt()) + byteReader.readBytes(valueOrOffset.toInt(), valueLength.toInt()) } else - offsetBytes - - val field = TiffField(tag, dirType, fieldType, count, offset, valueBytes, byteOrder, entryIndex) + valueOrOffsetBytes - fields.add(field) + fields.add( + TiffField(tag, dirType, fieldType, count, valueOrOffset, valueBytes, byteOrder, entryIndex) + ) } val nextDirectoryOffset = 0xFFFFFFFFL and @@ -225,6 +232,7 @@ class TiffReader : BinaryFileParser() { subDirectoryRead = readDirectory( byteReader = byteReader, + byteOrder = byteOrder, directoryOffset = subDirOffset.toLong(), dirType = subDirectoryType, collector = collector, @@ -246,6 +254,7 @@ class TiffReader : BinaryFileParser() { if (directory.nextDirectoryOffset > 0) readDirectory( byteReader = byteReader, + byteOrder = byteOrder, directoryOffset = directory.nextDirectoryOffset, dirType = dirType + 1, collector = collector, diff --git a/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/write/TiffImageWriterLossless.kt b/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/write/TiffImageWriterLossless.kt index 97026cbd..6ee68225 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/write/TiffImageWriterLossless.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/format/tiff/write/TiffImageWriterLossless.kt @@ -40,7 +40,7 @@ class TiffImageWriterLossless( val byteReader = ByteArrayByteReader(exifBytes) - val contents = TiffReader().read(byteReader) + val contents = TiffReader.read(byteReader) val elements = mutableListOf() diff --git a/src/commonMain/kotlin/com/ashampoo/kim/input/ByteReader.kt b/src/commonMain/kotlin/com/ashampoo/kim/input/ByteReader.kt index 22f025b6..a430d618 100644 --- a/src/commonMain/kotlin/com/ashampoo/kim/input/ByteReader.kt +++ b/src/commonMain/kotlin/com/ashampoo/kim/input/ByteReader.kt @@ -123,6 +123,9 @@ interface ByteReader : Closeable { fun skipBytes(name: String, length: Long) { + if (length == 0L) + return + var total: Long = 0 while (length != total) { @@ -130,7 +133,7 @@ interface ByteReader : Closeable { val skipped = readBytes(length.toInt()).size if (skipped < 1) - throw ImageReadException("$name ($skipped)") + throw ImageReadException("$name (skipped $skipped of $length bytes)") total += skipped } diff --git a/src/commonTest/kotlin/com/ashampoo/kim/format/jpeg/JpegOrientationOffsetFinderTest.kt b/src/commonTest/kotlin/com/ashampoo/kim/format/jpeg/JpegOrientationOffsetFinderTest.kt new file mode 100644 index 00000000..894bfb93 --- /dev/null +++ b/src/commonTest/kotlin/com/ashampoo/kim/format/jpeg/JpegOrientationOffsetFinderTest.kt @@ -0,0 +1,72 @@ +/* + * 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.input.ByteArrayByteReader +import com.ashampoo.kim.testdata.KimTestData +import kotlin.test.Test +import kotlin.test.assertEquals + +class JpegOrientationOffsetFinderTest { + + val expectedMap = mapOf( + 1 to 72, + 2 to 72, + 15 to 85, + 19 to 54, + 20 to 84, + 21 to 55, + 25 to 54, + 26 to 3447, + 28 to 66, + 29 to 114, + 31 to 54, + 34 to 72, + 36 to 73, + 37 to 114, + 38 to 102, + 39 to 66, + 40 to 54, + 41 to 55, + 44 to 54, + 45 to 54, + 48 to 55, + 49 to 54, + 50 to 54 + ) + + /** + * Regression test based on a fixed small set of test files. + */ + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFindOrientationOffset() { + + for (index in 1..KimTestData.HIGHEST_JPEG_INDEX) { + + val bytes = KimTestData.getBytesOf(index) + + val byteReader = ByteArrayByteReader(bytes) + + val orientationOffset = JpegOrientationOffsetFinder.findOrientationOffset(byteReader) + + assertEquals( + expected = expectedMap.get(index), + actual = orientationOffset + ) + } + } +} diff --git a/src/commonTest/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdaterTest.kt b/src/commonTest/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdaterTest.kt index 1f6c85ee..3531a256 100644 --- a/src/commonTest/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdaterTest.kt +++ b/src/commonTest/kotlin/com/ashampoo/kim/format/jpeg/JpegUpdaterTest.kt @@ -154,7 +154,10 @@ class JpegUpdaterTest { if (!equals) { - Path("build/$fileName").sink().use { it.write(actualBytes) } + SystemFileSystem + .sink(Path("build/$fileName")) + .buffered() + .use { it.write(actualBytes) } fail("Photo $fileName has not the expected bytes!") } diff --git a/src/commonTest/resources/com/ashampoo/kim/updates_jpg/rotated_right.jpg b/src/commonTest/resources/com/ashampoo/kim/updates_jpg/rotated_right.jpg index 5d6cf87e..942b0240 100644 Binary files a/src/commonTest/resources/com/ashampoo/kim/updates_jpg/rotated_right.jpg and b/src/commonTest/resources/com/ashampoo/kim/updates_jpg/rotated_right.jpg differ diff --git a/src/jvmTest/kotlin/com/ashampoo/kim/format/xmp/XmpWriterTest.kt b/src/jvmTest/kotlin/com/ashampoo/kim/format/xmp/XmpWriterTest.kt index a8bb8e66..260fa73a 100644 --- a/src/jvmTest/kotlin/com/ashampoo/kim/format/xmp/XmpWriterTest.kt +++ b/src/jvmTest/kotlin/com/ashampoo/kim/format/xmp/XmpWriterTest.kt @@ -22,7 +22,9 @@ import com.ashampoo.kim.model.PhotoRating import com.ashampoo.kim.model.TiffOrientation import com.ashampoo.kim.testdata.KimTestData import com.ashampoo.xmp.XMPMetaFactory +import kotlinx.io.buffered import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem import kotlinx.io.files.sink import kotlin.test.Test import kotlin.test.fail @@ -92,9 +94,10 @@ class XmpWriterTest { if (!equals) { - Path("build/${baseFileName}_mod.xmp").sink().use { - it.write(actualXmp.encodeToByteArray()) - } + SystemFileSystem + .sink(Path("build/${baseFileName}_mod.xmp")) + .buffered() + .use { it.write(actualXmp.encodeToByteArray()) } fail("Photo $baseFileName has not the expected bytes!") }