Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebP write support #73

Merged
merged 16 commits into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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
* WebP: Read EXIF & XMP
* WebP: Read & Write EXIF & XMP
* HEIC / AVIF: Read EXIF & XMP
* JXL: Read & Write EXIF & XMP of uncompressed files
* TIFF / RAW: Read EXIF & XMP
Expand All @@ -37,7 +37,7 @@ of Ashampoo Photos, which, in turn, is driven by user community feedback.
## Installation

```
implementation("com.ashampoo:kim:0.13.2")
implementation("com.ashampoo:kim:0.14")
```

## Sample usages
Expand Down
3 changes: 3 additions & 0 deletions src/commonMain/kotlin/com/ashampoo/kim/Kim.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.ashampoo.kim.format.raf.RafMetadataExtractor
import com.ashampoo.kim.format.raf.RafPreviewExtractor
import com.ashampoo.kim.format.rw2.Rw2PreviewExtractor
import com.ashampoo.kim.format.tiff.TiffReader
import com.ashampoo.kim.format.webp.WebPUpdater
import com.ashampoo.kim.input.ByteArrayByteReader
import com.ashampoo.kim.input.ByteReader
import com.ashampoo.kim.input.DefaultRandomAccessByteReader
Expand Down Expand Up @@ -218,6 +219,7 @@ object Kim {
return@tryWithImageWriteException when (imageFormat) {
ImageFormat.JPEG -> JpegUpdater.update(prePendingByteReader, byteWriter, update)
ImageFormat.PNG -> PngUpdater.update(prePendingByteReader, byteWriter, update)
ImageFormat.WEBP -> WebPUpdater.update(prePendingByteReader, byteWriter, update)
ImageFormat.JXL -> JxlUpdater.update(prePendingByteReader, byteWriter, update)
null -> throw ImageWriteException("Unknown or unsupported file format.")
else -> throw ImageWriteException("Can't embed metadata into $imageFormat.")
Expand All @@ -236,6 +238,7 @@ object Kim {
return@tryWithImageWriteException when (imageFormat) {
ImageFormat.JPEG -> JpegUpdater.updateThumbnail(bytes, thumbnailBytes)
ImageFormat.PNG -> PngUpdater.updateThumbnail(bytes, thumbnailBytes)
ImageFormat.WEBP -> WebPUpdater.updateThumbnail(bytes, thumbnailBytes)
ImageFormat.JXL -> JxlUpdater.updateThumbnail(bytes, thumbnailBytes)
null -> throw ImageWriteException("Unknown or unsupported file format.")
else -> throw ImageWriteException("Can't embed thumbnail into $imageFormat.")
Expand Down
17 changes: 12 additions & 5 deletions src/commonMain/kotlin/com/ashampoo/kim/format/jxl/JxlWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,18 @@ object JxlWriter {

for (box in modifiedBoxes) {

byteWriter.writeInt(box.size.toInt())
byteWriter.writeInt(
box.size.toInt(),
BMFFConstants.BMFF_BYTE_ORDER
)

byteWriter.write(box.type.bytes)

box.largeSize?.let {
byteWriter.writeLong(box.largeSize)
byteWriter.writeLong(
box.largeSize,
BMFFConstants.BMFF_BYTE_ORDER
)
}

byteWriter.write(box.payload)
Expand All @@ -116,11 +122,11 @@ object JxlWriter {

val size = BMFFConstants.BOX_HEADER_LENGTH + 4 + exifBytes.size

byteWriter.writeInt(size)
byteWriter.writeInt(size, BMFFConstants.BMFF_BYTE_ORDER)
byteWriter.write(BoxType.EXIF.bytes)

/* Version and flags, all zeros. */
byteWriter.writeInt(0)
byteWriter.writeInt(0, BMFFConstants.BMFF_BYTE_ORDER)

byteWriter.write(exifBytes)
}
Expand All @@ -131,7 +137,8 @@ object JxlWriter {

val size = BMFFConstants.BOX_HEADER_LENGTH + xmpBytes.size

byteWriter.writeInt(size)
byteWriter.writeInt(size, BMFFConstants.BMFF_BYTE_ORDER)

byteWriter.write(BoxType.XML.bytes)

byteWriter.write(xmpBytes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,28 @@ object PngImageParser : ImageParser {

val chunks = readChunks(byteReader, metadataChunkTypes)

if (chunks.isEmpty())
throw ImageReadException("Did not find any chunks in file.")

return@tryWithImageReadException parseMetadataFromChunks(chunks)
}

@Throws(ImageReadException::class)
fun parseMetadataFromChunks(chunks: List<PngChunk>): ImageMetadata =
tryWithImageReadException {

val imageSize = chunks.filterIsInstance<PngChunkIhdr>().first().imageSize
require(chunks.isNotEmpty()) {
"Given chunk list was empty."
}

val headerChunk = chunks.filterIsInstance<PngChunkIhdr>().firstOrNull()

checkNotNull(headerChunk) {
"Did not find mandatory IHDR chunk. " +
"Found chunk types: ${chunks.map { it.type }}"
}

val imageSize = headerChunk.imageSize

/*
* We attempt to read EXIF data from the EXIF chunk, which has been the standard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.ashampoo.kim.format.png

import com.ashampoo.kim.common.toHex
import com.ashampoo.kim.format.png.PngConstants.PNG_BYTE_ORDER
import com.ashampoo.kim.format.png.PngCrc.continuePartialCrc
import com.ashampoo.kim.format.png.PngCrc.finishPartialCrc
import com.ashampoo.kim.format.png.PngCrc.startPartialCrc
Expand Down Expand Up @@ -116,7 +117,7 @@ object PngWriter {

val dataLength = data?.size ?: 0

byteWriter.writeInt(dataLength)
byteWriter.writeInt(dataLength, PNG_BYTE_ORDER)
byteWriter.write(chunkType.bytes)

if (data != null)
Expand All @@ -131,7 +132,7 @@ object PngWriter {

val crc = finishPartialCrc(crc2).toInt()

byteWriter.writeInt(crc)
byteWriter.writeInt(crc, PNG_BYTE_ORDER)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,15 @@ object WebPConstants {

const val CHUNK_SIZE_LENGTH = 4

const val CHUNK_HEADER_LENGTH = WebPConstants.TPYE_LENGTH + WebPConstants.CHUNK_SIZE_LENGTH

const val VP8X_PAYLOAD_LENGTH = 10

/**
* 16383 x 16383 pixels is the max size for an WebP
*
* https://developers.google.com/speed/webp/faq#what_is_the_maximum_size_a_webp_image_can_be
*/
const val MAX_SIDE_LENGTH: Int = 16383

}
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,29 @@ object WebPImageParser : ImageParser {
override fun parseMetadata(byteReader: ByteReader): ImageMetadata =
tryWithImageReadException {

val chunks = readChunks(byteReader)
val chunks = readChunks(
byteReader = byteReader,
stopAfterMetadataRead = true
)

if (chunks.isEmpty())
throw ImageReadException("Did not find any chunks in file.")

parseMetadataFromChunks(chunks)
}

@Throws(ImageReadException::class)
fun parseMetadataFromChunks(chunks: List<WebPChunk>): ImageMetadata =
tryWithImageReadException {

val imageSizeAwareChunk = chunks.filterIsInstance<ImageSizeAware>().firstOrNull()

val imageSize = chunks.filterIsInstance<ImageSizeAware>().first().imageSize
checkNotNull(imageSizeAwareChunk) {
"Did not find a header chunk containing the image size. " +
"Found chunk types: ${chunks.map { it.type }}"
}

val imageSize = imageSizeAwareChunk.imageSize

val exifChunk = chunks.filterIsInstance<WebPChunkExif>().firstOrNull()
val xmpChunk = chunks.filterIsInstance<WebPChunkXmp>().firstOrNull()
Expand All @@ -60,7 +80,8 @@ object WebPImageParser : ImageParser {
}

fun readChunks(
byteReader: ByteReader
byteReader: ByteReader,
stopAfterMetadataRead: Boolean = false
): List<WebPChunk> = tryWithImageReadException {

byteReader.readAndVerifyBytes("RIFF signature", RIFF_SIGNATURE)
Expand All @@ -69,12 +90,17 @@ object WebPImageParser : ImageParser {

byteReader.readAndVerifyBytes("WEBP signature", WEBP_SIGNATURE)

return readChunksInternal(byteReader, length - WEBP_SIGNATURE.size)
return readChunksInternal(
byteReader = byteReader,
bytesToRead = length - WEBP_SIGNATURE.size,
stopAfterMetadataRead = stopAfterMetadataRead
)
}

private fun readChunksInternal(
byteReader: ByteReader,
bytesToRead: Int
bytesToRead: Int,
stopAfterMetadataRead: Boolean
): List<WebPChunk> {

val chunks = mutableListOf<WebPChunk>()
Expand Down Expand Up @@ -118,6 +144,27 @@ object WebPImageParser : ImageParser {
}

chunks.add(chunk)

/*
* After reading the header we can decide if we need to
* read the rest of the file for metadata.
*/
if (stopAfterMetadataRead) {

/*
* Older chunk header types do not support Exif & XMP.
* So we can stop right here for those old formats.
*/
if (chunkType == WebPChunkType.VP8 && chunkType == WebPChunkType.VP8L)
break

/*
* If the header reveals that there will be no EXIF and no XMP
* we don't need to read the whole file.
*/
if (chunk is WebPChunkVP8X && !chunk.hasExif && !chunk.hasXmp)
break
}
}

return chunks
Expand Down
128 changes: 128 additions & 0 deletions src/commonMain/kotlin/com/ashampoo/kim/format/webp/WebPUpdater.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* 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.format.webp

import com.ashampoo.kim.common.ImageWriteException
import com.ashampoo.kim.common.startsWithNullable
import com.ashampoo.kim.common.tryWithImageWriteException
import com.ashampoo.kim.format.ImageFormatMagicNumbers
import com.ashampoo.kim.format.MetadataUpdater
import com.ashampoo.kim.format.tiff.write.TiffOutputSet
import com.ashampoo.kim.format.tiff.write.TiffWriterBase
import com.ashampoo.kim.format.xmp.XmpWriter
import com.ashampoo.kim.input.ByteArrayByteReader
import com.ashampoo.kim.input.ByteReader
import com.ashampoo.kim.model.MetadataUpdate
import com.ashampoo.kim.output.ByteArrayByteWriter
import com.ashampoo.kim.output.ByteWriter
import com.ashampoo.xmp.XMPMeta
import com.ashampoo.xmp.XMPMetaFactory

internal object WebPUpdater : MetadataUpdater {

@Throws(ImageWriteException::class)
override fun update(
byteReader: ByteReader,
byteWriter: ByteWriter,
update: MetadataUpdate
) = tryWithImageWriteException {

val chunks = WebPImageParser.readChunks(byteReader, stopAfterMetadataRead = false)

val metadata = WebPImageParser.parseMetadataFromChunks(chunks)

val xmpMeta: XMPMeta = if (metadata.xmp != null)
XMPMetaFactory.parseFromString(metadata.xmp)
else
XMPMetaFactory.create()

val updatedXmp = XmpWriter.updateXmp(xmpMeta, update, true)

val isExifUpdate = update is MetadataUpdate.Orientation ||
update is MetadataUpdate.TakenDate ||
update is MetadataUpdate.GpsCoordinates

val exifBytes: ByteArray? = if (isExifUpdate) {

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

outputSet.applyUpdate(update)

val exifBytesWriter = ByteArrayByteWriter()

TiffWriterBase
.createTiffWriter(
byteOrder = outputSet.byteOrder,
oldExifBytes = metadata.exifBytes
)
.write(exifBytesWriter, outputSet)

exifBytesWriter.toByteArray()

} else {
null
}

WebPWriter.writeImage(
chunks = chunks,
byteWriter = byteWriter,
exifBytes = exifBytes,
xmp = updatedXmp
)
}

@Throws(ImageWriteException::class)
override fun updateThumbnail(
bytes: ByteArray,
thumbnailBytes: ByteArray
): ByteArray = tryWithImageWriteException {

if (!bytes.startsWithNullable(ImageFormatMagicNumbers.webP))
throw ImageWriteException("Provided input bytes are not WebP!")

val byteReader = ByteArrayByteReader(bytes)

val chunks = WebPImageParser.readChunks(byteReader, stopAfterMetadataRead = false)

val metadata = WebPImageParser.parseMetadataFromChunks(chunks)

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

outputSet.setThumbnailBytes(thumbnailBytes)

val exifBytesWriter = ByteArrayByteWriter()

TiffWriterBase
.createTiffWriter(
byteOrder = outputSet.byteOrder,
oldExifBytes = metadata.exifBytes
)
.write(exifBytesWriter, outputSet)

val exifBytes = exifBytesWriter.toByteArray()

val byteWriter = ByteArrayByteWriter()

WebPWriter.writeImage(
chunks = chunks,
byteWriter = byteWriter,
exifBytes = exifBytes,
xmp = null // No change to XMP
)

return byteWriter.toByteArray()
}
}
Loading
Loading