Skip to content

Commit

Permalink
AVIF & JPEG XL read support (#61)
Browse files Browse the repository at this point in the history
  • Loading branch information
StefanOltmann authored Jan 20, 2024
1 parent 310bc14 commit 5ce748e
Show file tree
Hide file tree
Showing 135 changed files with 2,267 additions and 371 deletions.
21 changes: 10 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ It's part of [Ashampoo Photos](https://ashampoo.com/photos).
* JPG: Read & Write EXIF, IPTC & XMP
* PNG: Read & Write `eXIf` chunk & XMP
+ Also read non-standard EXIF & IPTC from `tEXt`/`zTXt` chunk
* ISOBMFF (HEIC): Read EXIF & XMP
+ Somewhat experimental as only tested for iPhone & Samsung HEIC files so far
* HEIC / AVIF: Read EXIF & XMP
* JPEG XL: Read EXIF & XMP
* TIFF / DNG / RAW: Read EXIF & XMP
+ Good support for Canon CR2, Fujifilm RAF & Adobe DNG
+ Experimental support for NEF, ARW, RW2 & ORF with known issues
Expand All @@ -35,7 +35,7 @@ of Ashampoo Photos, which, in turn, is driven by user community feedback.
## Installation

```
implementation("com.ashampoo:kim:0.9.3")
implementation("com.ashampoo:kim:0.10")
```

## Sample usages
Expand Down Expand Up @@ -140,16 +140,15 @@ val newBytes = Kim.updateThumbnail(
## Limitations

* Inability to update EXIF, IPTC and XMP in JPG files simultaneously.
* HEIC files are only tested for iPhone SE 3 & Samsung Galaxy S21.
* Does not read the image size and orientation for HEIC, AVIF & JPEG XL

### Regarding missing HEIC metadata
### Regarding HEIC & AVIF metadata

In the processing of HEIC files, we handle them as standard ISO base
media file format (ISOBMFF) files, adhering rigorously to the
EIC/ISO 14496-12 specification. To preempt potential legal issues,
we intentionally omit certain features outlined in the HEIC specification,
notably the image size ("ispe") and image rotation ("irot") boxes.
This precautionary approach extends to AVIF images, as they repurpose these same boxes.
In the processing of HEIC and AVIF files, we handle them as standard
ISOBMFF-based files, adhering rigorously to the EIC/ISO 14496-12 specification.
To preempt potential legal issues, we intentionally omit certain boxes outlined
in the HEIC specification, notably the image size ("ispe") and image rotation ("irot") boxes.
This approach extends to AVIF images, as they repurpose the same boxes.

## Contributions

Expand Down
5 changes: 4 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ kotlin {
dependsOn(ktorMain)
}

@Suppress("UnusedPrivateMember", "UNUSED_VARIABLE") // False positive
val jvmMain by sourceSets.getting {

dependsOn(commonMain)
Expand All @@ -232,7 +233,9 @@ kotlin {

@Suppress("UnusedPrivateMember", "UNUSED_VARIABLE") // False positive
val androidMain by sourceSets.getting {
dependsOn(jvmMain)

dependsOn(commonMain)
dependsOn(ktorMain)
}

@Suppress("UnusedPrivateMember", "UNUSED_VARIABLE") // False positive
Expand Down
18 changes: 14 additions & 4 deletions src/androidMain/kotlin/com/ashampoo/kim/AndroidKimExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,21 @@ package com.ashampoo.kim
import com.ashampoo.kim.common.ImageReadException
import com.ashampoo.kim.format.ImageMetadata
import com.ashampoo.kim.input.AndroidInputStreamByteReader
import java.io.File
import java.io.InputStream

/**
* Provides way to read from Android ContentReolver that should work on all versions.
*/
@Throws(ImageReadException::class)
fun Kim.readMetadataAndroid(inputStream: InputStream, length: Long): ImageMetadata? =
fun Kim.readMetadata(inputStream: InputStream, length: Long): ImageMetadata? =
Kim.readMetadata(AndroidInputStreamByteReader(inputStream, length))

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

@Throws(ImageReadException::class)
fun Kim.readMetadata(file: File): ImageMetadata? {

check(file.exists()) { "File does not exist: $file" }

return Kim.readMetadata(file.inputStream(), file.length())
}
72 changes: 72 additions & 0 deletions src/androidMain/kotlin/com/ashampoo/kim/common/ZLib.android.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2024 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 java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.Inflater

private const val ZLIB_BUFFER_SIZE: Int = 1024

actual fun compress(input: String): ByteArray {

val deflater = Deflater()
val inputBytes = input.toByteArray()

deflater.setInput(inputBytes)
deflater.finish()

val outputStream = ByteArrayOutputStream(inputBytes.size)

val buffer = ByteArray(ZLIB_BUFFER_SIZE)

while (!deflater.finished()) {

val count = deflater.deflate(buffer)

outputStream.write(buffer, 0, count)
}

deflater.end()

return outputStream.toByteArray()
}

actual fun decompress(byteArray: ByteArray): String {

val inflater = Inflater()
val outputStream = ByteArrayOutputStream()

return outputStream.use {

val buffer = ByteArray(ZLIB_BUFFER_SIZE)

inflater.setInput(byteArray)

var count = -1

while (count != 0) {

count = inflater.inflate(buffer)

outputStream.write(buffer, 0, count)
}

inflater.end()

String(outputStream.toByteArray())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ package com.ashampoo.kim.input
import com.ashampoo.kim.common.slice
import java.io.InputStream

/**
* Provides way to read from Android ContentReolver that
* should work on all versions.
*/
open class AndroidInputStreamByteReader(
private val inputStream: InputStream,
override val contentLength: Long
Expand All @@ -37,9 +41,9 @@ open class AndroidInputStreamByteReader(

/*
* InputStream.readNBytes(count) is not available
* on older Android versions.
* on older Android versions. So we need to read
* into a buffer.
*/

val buffer = ByteArray(count)

val bytes = inputStream.read(buffer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ 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.tiff.constant.ExifTag
import com.ashampoo.kim.format.tiff.constant.TiffConstants
import com.ashampoo.kim.format.tiff.constant.TiffTag
import com.ashampoo.kim.format.xmp.XmpReader
import com.ashampoo.kim.model.GpsCoordinates
import com.ashampoo.kim.model.PhotoMetadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,21 @@ object ImageFormatMagicNumbers {
null, null, null, null
).plus("ftyphevx".encodeToByteArray().toList())

val jxlCodeStream: List<Byte> = byteListOf(
0xFF, 0x0A
)
/* 4 bytes + "ftypavif" */
val avif: List<Byte?> = byteListOf(
null, null, null, null
).plus("ftypavif".encodeToByteArray().toList())

val jxlContainer: List<Byte> = byteListOf(
/* The regular ISOBMFF-based JPEG XL */
val jxl: List<Byte> = byteListOf(
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A
)

/* The 'naked' code stream without metadata. */
val jxlCodeStream: List<Byte> = byteListOf(
0xFF, 0x0A
)

private fun byteListOf(vararg ints: Int?): List<Byte?> =
ints.map { it?.toByte() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import com.ashampoo.kim.format.jpeg.iptc.IptcMetadata
import com.ashampoo.kim.format.tiff.TiffContents
import com.ashampoo.kim.format.tiff.TiffDirectory
import com.ashampoo.kim.format.tiff.TiffField
import com.ashampoo.kim.format.tiff.taginfos.TagInfo
import com.ashampoo.kim.format.tiff.taginfo.TagInfo
import com.ashampoo.kim.model.ImageFormat
import com.ashampoo.kim.model.ImageSize

Expand Down
3 changes: 2 additions & 1 deletion src/commonMain/kotlin/com/ashampoo/kim/format/ImageParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ fun interface ImageParser {
ImageFormat.RAF -> RafImageParser

ImageFormat.HEIC -> BaseMediaFileFormatImageParser
// ImageFormat.JXL -> BaseMediaFileFormatImageParser
ImageFormat.AVIF -> BaseMediaFileFormatImageParser
ImageFormat.JXL -> BaseMediaFileFormatImageParser

else -> null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import com.ashampoo.kim.common.ImageReadException
import com.ashampoo.kim.common.tryWithImageReadException
import com.ashampoo.kim.format.TiffPreviewExtractor
import com.ashampoo.kim.format.tiff.TiffContents
import com.ashampoo.kim.format.tiff.constants.TiffTag
import com.ashampoo.kim.format.tiff.constant.TiffTag
import com.ashampoo.kim.input.RandomAccessByteReader

object ArwPreviewExtractor : TiffPreviewExtractor {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ object BMFFConstants {

const val TIFF_HEADER_OFFSET_BYTE_COUNT = 4

const val ITEM_TYPE_EXIF = 1165519206
const val ITEM_TYPE_MIME = 1835625829
const val ITEM_TYPE_JPEG = 1785750887
const val ITEM_TYPE_EXIF = 1_165_519_206
const val ITEM_TYPE_MIME = 1_835_625_829
const val ITEM_TYPE_JPEG = 1_785_750_887
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import com.ashampoo.kim.format.ImageMetadata
import com.ashampoo.kim.format.ImageParser
import com.ashampoo.kim.format.bmff.BMFFConstants.BMFF_BYTE_ORDER
import com.ashampoo.kim.format.bmff.BMFFConstants.TIFF_HEADER_OFFSET_BYTE_COUNT
import com.ashampoo.kim.format.bmff.boxes.MetaBox
import com.ashampoo.kim.format.bmff.box.FileTypeBox
import com.ashampoo.kim.format.bmff.box.MetaBox
import com.ashampoo.kim.format.tiff.TiffReader
import com.ashampoo.kim.input.ByteArrayByteReader
import com.ashampoo.kim.input.ByteReader
Expand All @@ -31,7 +32,8 @@ import com.ashampoo.kim.input.PositionTrackingByteReaderDecorator

/**
* Reads containers that follow the ISO base media file format
* as defined in ISO/IEC 14496-12. Examples for these are MP4, HEIC & JPEG XL.
* as defined in ISO/IEC 14496-12.
* Examples for these are HEIC, AVIF & JPEG XL.
*
* https://en.wikipedia.org/wiki/ISO_base_media_file_format
*/
Expand All @@ -46,10 +48,26 @@ object BaseMediaFileFormatImageParser : ImageParser {

val allBoxes = BoxReader.readBoxes(
byteReader = copyByteReader,
stopAfterMetaBox = true,
stopAfterMetadataRead = true,
offsetShift = 0
)

if (allBoxes.isEmpty())
throw ImageReadException("Illegal ISOBMFF: Has no boxes.")

val fileTypeBox = allBoxes.find { it.type == BoxType.FTYP } as? FileTypeBox

if (fileTypeBox == null)
throw ImageReadException("Illegal ISOBMFF: Has no 'ftyp' Box.")

/**
* Handle JPEG XL
*
* This format has EXIF & XMP neatly in dedicated boxes, so we can just extract these.
*/
if (fileTypeBox.majorBrand == FileTypeBox.JXL_BRAND)
return JxlHandler.createMetadata(allBoxes)

val metaBox = allBoxes.find { it.type == BoxType.META } as? MetaBox

if (metaBox == null)
Expand Down
Loading

0 comments on commit 5ce748e

Please sign in to comment.