
Read and write image metadata across formats, including EXIF/IPTC/XMP; lossless single-byte rotation, thumbnail updates, RAW preview extraction and high-level photo summaries.
Kim is a Kotlin Multiplatform library for reading and writing image metadata.
This lib is used in production by my online EXIF Viewer, Thumbnail Fixer Pro, Quick Metadata Remover and PixelSafe.
eXIf chunk & XMP
tEXt/zTXt chunkKim.update() API to perform updates to the relevant places
Kim.deleteMetadata() API to remove all metadata, keeping the ICC profileimplementation("de.stefan-oltmann:kim:<VERSION>")
For the targets wasmJs & js you also need to specify this:
implementation(npm("pako", "2.1.0"))
Kim.readMetadata() takes kotlin.ByteArray on all platforms and depending on the platform also
kotlinx.io.files.Path, kotlinx.io.Source (for usage with Ktor) & ByteReadChannel,
java.io.File, java.io.InputStream, NSData (iOS) and String paths.
val bytes: ByteArray = loadBytes()
val metadata = Kim.readMetadata(bytes)
/* MediaMetadata has a proper toString() similar to the output of ExifTool */
println(metadata)
val orientation = metadata.findShortValue(TiffTag.TIFF_TAG_ORIENTATION)
println("Orientation: $orientation")
val takenDate = metadata.findStringValue(ExifTag.EXIF_TAG_DATE_TIME_ORIGINAL)
println("Taken date: $takenDate")For streaming sources, Kim.readMetadata() also takes a ByteReader, so the file does not have to
be loaded into memory:
val byteReader = JvmInputStreamByteReader(inputFile.inputStream(), inputFile.length())
val metadata = Kim.readMetadata(byteReader)This creates an instance of MetadataSummary. It contains the following:
XMP:pick flagval bytes: ByteArray = loadBytes()
val summary = Kim.readMetadata(bytes).convertToSummary()Kim.extractMetadataBytes() determines the file type from the file header and returns the raw
metadata bytes. Cloud services can not reliably tell the mime type, so this can be used to upload
the metadata alongside the image.
val result = Kim.extractMetadataBytes(byteReader)
/* The detected media format, or NULL when it could not be determined. */
val mediaFormat: MediaFormat? = result.first
/* The raw metadata bytes to upload to the cloud service. */
val metadataBytes: ByteArray = result.secondKim.extractPreviewImage() extracts the embedded preview image of DNG, CR2, CR3, RAF, NEF, ARW,
RW2 & ORF files as JPEG bytes.
val previewBytes: ByteArray? = Kim.extractPreviewImage(byteReader)
if (previewBytes != null)
println("Preview image has ${previewBytes.size} bytes.")val inputFile = File("myphoto.jpg")
val outputFile = File("myphoto_changed.jpg")
val metadata = Kim.readMetadata(inputFile)
val outputSet: TiffOutputSet = metadata.exif?.createOutputSet() ?: TiffOutputSet()
val rootDirectory = outputSet.getOrCreateRootDirectory()
rootDirectory.removeField(TiffTag.TIFF_TAG_ORIENTATION)
rootDirectory.add(TiffTag.TIFF_TAG_ORIENTATION, 8)
OutputStreamByteWriter(outputFile.outputStream()).use { outputStreamByteWriter ->
JpegRewriter.updateExifMetadata(
byteReader = JvmInputStreamByteReader(inputFile.inputStream(), inputFile.length()),
byteWriter = outputStreamByteWriter,
outputSet = outputSet
)
}See the example project for more details.
Kim.update() applies the given updates to all formats that can represent them, so EXIF, IPTC and
XMP are updated simultaneously in one call.
val bytes: ByteArray = loadBytes()
/* A single update: */
val rotatedBytes = Kim.update(
bytes = bytes,
update = MetadataUpdate.Orientation(TiffOrientation.ROTATE_RIGHT)
)
/* Multiple updates in one call: */
val updatedBytes = Kim.update(
bytes = bytes,
updates = setOf(
MetadataUpdate.Orientation(TiffOrientation.ROTATE_RIGHT),
MetadataUpdate.TakenDate(timestamp),
MetadataUpdate.Title("My title"),
MetadataUpdate.Keywords(setOf("hello", "test"))
)
)The supported update types are:
| Update | Sets |
|---|---|
MetadataUpdate.Orientation |
Rotation (JPG supports a lossless single-byte swap) |
MetadataUpdate.TakenDate |
Date taken |
MetadataUpdate.GpsCoordinates |
GPS coordinates |
MetadataUpdate.LocationShown |
Location shown |
MetadataUpdate.GpsCoordinatesAndLocationShown |
GPS coordinates and location |
MetadataUpdate.Title |
Title |
MetadataUpdate.Description |
Description |
MetadataUpdate.Flagged |
The XMP:pick flag |
MetadataUpdate.Rating |
Star rating |
MetadataUpdate.Keywords |
Keywords |
MetadataUpdate.Faces |
Faces (XMP-mwg-rs regions) |
MetadataUpdate.Persons |
Persons in image |
An update call without any updates is rejected with an ImageWriteException.
See AbstractUpdaterTest for more samples.
The update can stream the file from a ByteReader to a ByteWriter. The image data of JPEG, PNG,
GIF and JPEG XL files with split codestream boxes (jxlp) is streamed in bounded chunks. WebP files
buffer their chunks in memory, and JPEG XL files with a single codestream box (jxlc) buffer the
codestream, because the metadata is stored behind the image data. A single-update overload exists
for both the byte array and the streaming variant.
val byteReader = JvmInputStreamByteReader(inputFile.inputStream(), inputFile.length())
OutputStreamByteWriter(outputFile.outputStream()).use { outputStreamByteWriter ->
Kim.update(
byteReader = byteReader,
byteWriter = outputStreamByteWriter,
updates = setOf(MetadataUpdate.Orientation(TiffOrientation.ROTATE_RIGHT))
)
}Kim.deleteMetadata() removes all metadata of a file, but keeps the ICC chunks, because they would
change how the image is displayed.
eXIf chunk, all text chunks & the tIME chunkval bytes: ByteArray = loadBytes()
val newBytes = Kim.deleteMetadata(bytes)Like Kim.update(), deleteMetadata() also offers a streaming overload that writes to a
ByteWriter without loading the file into memory for the formats listed in
the Streaming update section:
Kim.deleteMetadata(
byteReader = byteReader,
byteWriter = byteWriter
)val bytes: ByteArray = loadBytes()
val thumbnailBytes: ByteArray = loadThumbnailBytes()
val newBytes = Kim.updateThumbnail(
bytes = bytes,
thumbnailBytes = thumbnailBytes
)See the Java example project how to use Kim in Java projects.
jxlc). JPEG, PNG, GIF and JPEG XL files with split codestream boxes (jxlp) are
streamed in bounded chunks.ilst tags (title, keywords as written by Apple tools) and the QuickTime GPS
tag are not read yet; such videos report the XMP packet, the display resolution and - when
present - the Fujifilm metadata only.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.
On Android 10 (API 29) and above the platform only hands out the GPS coordinates of media files
when the app holds the ACCESS_MEDIA_LOCATION permission. Without it the GPS tags of photos that
are read through a ContentResolver stream come back empty or corrupted, which is easy to mistake
for a library bug. This is platform behavior - Kim cannot bypass it.
Declare the permission in your manifest and request it at runtime like other dangerous permissions if your app needs GPS metadata:
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>Contributions to Kim are welcome! If you encounter any issues, have suggestions for improvements, or would like to contribute new features, please feel free to submit a pull request.
This code is under the Apache License 2.0.
See the NOTICE.txt file for required notices and attributions.
Kim is a Kotlin Multiplatform library for reading and writing image metadata.
This lib is used in production by my online EXIF Viewer, Thumbnail Fixer Pro, Quick Metadata Remover and PixelSafe.
eXIf chunk & XMP
tEXt/zTXt chunkKim.update() API to perform updates to the relevant places
Kim.deleteMetadata() API to remove all metadata, keeping the ICC profileimplementation("de.stefan-oltmann:kim:<VERSION>")
For the targets wasmJs & js you also need to specify this:
implementation(npm("pako", "2.1.0"))
Kim.readMetadata() takes kotlin.ByteArray on all platforms and depending on the platform also
kotlinx.io.files.Path, kotlinx.io.Source (for usage with Ktor) & ByteReadChannel,
java.io.File, java.io.InputStream, NSData (iOS) and String paths.
val bytes: ByteArray = loadBytes()
val metadata = Kim.readMetadata(bytes)
/* MediaMetadata has a proper toString() similar to the output of ExifTool */
println(metadata)
val orientation = metadata.findShortValue(TiffTag.TIFF_TAG_ORIENTATION)
println("Orientation: $orientation")
val takenDate = metadata.findStringValue(ExifTag.EXIF_TAG_DATE_TIME_ORIGINAL)
println("Taken date: $takenDate")For streaming sources, Kim.readMetadata() also takes a ByteReader, so the file does not have to
be loaded into memory:
val byteReader = JvmInputStreamByteReader(inputFile.inputStream(), inputFile.length())
val metadata = Kim.readMetadata(byteReader)This creates an instance of MetadataSummary. It contains the following:
XMP:pick flagval bytes: ByteArray = loadBytes()
val summary = Kim.readMetadata(bytes).convertToSummary()Kim.extractMetadataBytes() determines the file type from the file header and returns the raw
metadata bytes. Cloud services can not reliably tell the mime type, so this can be used to upload
the metadata alongside the image.
val result = Kim.extractMetadataBytes(byteReader)
/* The detected media format, or NULL when it could not be determined. */
val mediaFormat: MediaFormat? = result.first
/* The raw metadata bytes to upload to the cloud service. */
val metadataBytes: ByteArray = result.secondKim.extractPreviewImage() extracts the embedded preview image of DNG, CR2, CR3, RAF, NEF, ARW,
RW2 & ORF files as JPEG bytes.
val previewBytes: ByteArray? = Kim.extractPreviewImage(byteReader)
if (previewBytes != null)
println("Preview image has ${previewBytes.size} bytes.")val inputFile = File("myphoto.jpg")
val outputFile = File("myphoto_changed.jpg")
val metadata = Kim.readMetadata(inputFile)
val outputSet: TiffOutputSet = metadata.exif?.createOutputSet() ?: TiffOutputSet()
val rootDirectory = outputSet.getOrCreateRootDirectory()
rootDirectory.removeField(TiffTag.TIFF_TAG_ORIENTATION)
rootDirectory.add(TiffTag.TIFF_TAG_ORIENTATION, 8)
OutputStreamByteWriter(outputFile.outputStream()).use { outputStreamByteWriter ->
JpegRewriter.updateExifMetadata(
byteReader = JvmInputStreamByteReader(inputFile.inputStream(), inputFile.length()),
byteWriter = outputStreamByteWriter,
outputSet = outputSet
)
}See the example project for more details.
Kim.update() applies the given updates to all formats that can represent them, so EXIF, IPTC and
XMP are updated simultaneously in one call.
val bytes: ByteArray = loadBytes()
/* A single update: */
val rotatedBytes = Kim.update(
bytes = bytes,
update = MetadataUpdate.Orientation(TiffOrientation.ROTATE_RIGHT)
)
/* Multiple updates in one call: */
val updatedBytes = Kim.update(
bytes = bytes,
updates = setOf(
MetadataUpdate.Orientation(TiffOrientation.ROTATE_RIGHT),
MetadataUpdate.TakenDate(timestamp),
MetadataUpdate.Title("My title"),
MetadataUpdate.Keywords(setOf("hello", "test"))
)
)The supported update types are:
| Update | Sets |
|---|---|
MetadataUpdate.Orientation |
Rotation (JPG supports a lossless single-byte swap) |
MetadataUpdate.TakenDate |
Date taken |
MetadataUpdate.GpsCoordinates |
GPS coordinates |
MetadataUpdate.LocationShown |
Location shown |
MetadataUpdate.GpsCoordinatesAndLocationShown |
GPS coordinates and location |
MetadataUpdate.Title |
Title |
MetadataUpdate.Description |
Description |
MetadataUpdate.Flagged |
The XMP:pick flag |
MetadataUpdate.Rating |
Star rating |
MetadataUpdate.Keywords |
Keywords |
MetadataUpdate.Faces |
Faces (XMP-mwg-rs regions) |
MetadataUpdate.Persons |
Persons in image |
An update call without any updates is rejected with an ImageWriteException.
See AbstractUpdaterTest for more samples.
The update can stream the file from a ByteReader to a ByteWriter. The image data of JPEG, PNG,
GIF and JPEG XL files with split codestream boxes (jxlp) is streamed in bounded chunks. WebP files
buffer their chunks in memory, and JPEG XL files with a single codestream box (jxlc) buffer the
codestream, because the metadata is stored behind the image data. A single-update overload exists
for both the byte array and the streaming variant.
val byteReader = JvmInputStreamByteReader(inputFile.inputStream(), inputFile.length())
OutputStreamByteWriter(outputFile.outputStream()).use { outputStreamByteWriter ->
Kim.update(
byteReader = byteReader,
byteWriter = outputStreamByteWriter,
updates = setOf(MetadataUpdate.Orientation(TiffOrientation.ROTATE_RIGHT))
)
}Kim.deleteMetadata() removes all metadata of a file, but keeps the ICC chunks, because they would
change how the image is displayed.
eXIf chunk, all text chunks & the tIME chunkval bytes: ByteArray = loadBytes()
val newBytes = Kim.deleteMetadata(bytes)Like Kim.update(), deleteMetadata() also offers a streaming overload that writes to a
ByteWriter without loading the file into memory for the formats listed in
the Streaming update section:
Kim.deleteMetadata(
byteReader = byteReader,
byteWriter = byteWriter
)val bytes: ByteArray = loadBytes()
val thumbnailBytes: ByteArray = loadThumbnailBytes()
val newBytes = Kim.updateThumbnail(
bytes = bytes,
thumbnailBytes = thumbnailBytes
)See the Java example project how to use Kim in Java projects.
jxlc). JPEG, PNG, GIF and JPEG XL files with split codestream boxes (jxlp) are
streamed in bounded chunks.ilst tags (title, keywords as written by Apple tools) and the QuickTime GPS
tag are not read yet; such videos report the XMP packet, the display resolution and - when
present - the Fujifilm metadata only.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.
On Android 10 (API 29) and above the platform only hands out the GPS coordinates of media files
when the app holds the ACCESS_MEDIA_LOCATION permission. Without it the GPS tags of photos that
are read through a ContentResolver stream come back empty or corrupted, which is easy to mistake
for a library bug. This is platform behavior - Kim cannot bypass it.
Declare the permission in your manifest and request it at runtime like other dangerous permissions if your app needs GPS metadata:
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>Contributions to Kim are welcome! If you encounter any issues, have suggestions for improvements, or would like to contribute new features, please feel free to submit a pull request.
This code is under the Apache License 2.0.
See the NOTICE.txt file for required notices and attributions.