Skip to content

Commit

Permalink
Support lossless WebP
Browse files Browse the repository at this point in the history
  • Loading branch information
takahirom committed Nov 1, 2024
1 parent d4ff5c6 commit aab7c28
Show file tree
Hide file tree
Showing 13 changed files with 271 additions and 63 deletions.
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ kotlinx-io = "0.3.3"
webjar-material-design-icons = "4.0.0"
webjar-materialize = "1.0.0"
webjars-locator-lite = "0.0.6"
webpImageio = "0.3.3"

composable-preview-scanner = "0.3.2"

Expand Down Expand Up @@ -99,3 +100,4 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
webjars-material-design-icons = { module = "org.webjars:material-design-icons", version.ref = "webjar-material-design-icons" }
webjars-materialize = { module = "org.webjars:materializecss", version.ref = "webjar-materialize" }
webjars-locator-lite = { module = "org.webjars:webjars-locator-lite", version.ref = "webjars-locator-lite" }
webp-imageio = { module = "io.github.darkxanter:webp-imageio", version.ref = "webpImageio" }
1 change: 1 addition & 0 deletions include-build/roborazzi-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ kotlin {
commonJvmMain {
dependencies {
implementation libs.junit
compileOnly(libs.webp.imageio)
}
}
commonJvmTest {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.github.takahirom.roborazzi

import com.luciad.imageio.webp.WebPWriteParam
import java.awt.image.BufferedImage
import java.awt.image.RenderedImage
import java.io.File
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageTypeSpecifier
import javax.imageio.ImageWriteParam
import javax.imageio.ImageWriter
import javax.imageio.metadata.IIOMetadata
import javax.imageio.metadata.IIOMetadataFormatImpl
import javax.imageio.metadata.IIOMetadataNode
import javax.imageio.stream.FileImageOutputStream

fun interface AwtImageWriter {
fun write(
dest: File,
contextData: Map<String, Any>,
bufferedImage: BufferedImage
)
}

fun interface AwtImageLoader {
fun load(file: File): BufferedImage
}

open class JvmPlatformRecordOptions(
val awtImageWriter: AwtImageWriter = AwtImageWriter { file, contextData, scaledBufferedImage ->
val imageExtension = file.extension.ifBlank { "png" }
if (contextData.isEmpty()) {
ImageIO.write(
scaledBufferedImage,
imageExtension,
file
)
return@AwtImageWriter
}
val writer = getWriter(scaledBufferedImage, imageExtension)
val meta = writer.writeMetadata(contextData, scaledBufferedImage)
writer.output = ImageIO.createImageOutputStream(file)
writer.write(IIOImage(scaledBufferedImage, null, meta))
},
var awtImageLoader: AwtImageLoader = AwtImageLoader { ImageIO.read(it) }
) : PlatformRecordOptions


@ExperimentalRoborazziApi
fun getWriter(renderedImage: RenderedImage, extension: String): ImageWriter {
val typeSpecifier = ImageTypeSpecifier.createFromRenderedImage(renderedImage)
val iterator: Iterator<*> = ImageIO.getImageWriters(typeSpecifier, extension)
return if (iterator.hasNext()) {
iterator.next() as ImageWriter
} else {
throw IllegalArgumentException("No ImageWriter found for $extension")
}
}

@ExperimentalRoborazziApi
fun ImageWriter.writeMetadata(
contextData: Map<String, Any>,
bufferedImage: BufferedImage,
): IIOMetadata? {
val meta = getDefaultImageMetadata(ImageTypeSpecifier(bufferedImage), null) ?: run {
// If we use WebP, it seems that we can't get the metadata
return null
}

val root = IIOMetadataNode(IIOMetadataFormatImpl.standardMetadataFormatName)
contextData.forEach { (key, value) ->
val textEntry = IIOMetadataNode("TextEntry")
textEntry.setAttribute("keyword", key)
textEntry.setAttribute("value", value.toString())
val text = IIOMetadataNode("Text")
text.appendChild(textEntry)
root.appendChild(text)
}

meta.mergeTree(IIOMetadataFormatImpl.standardMetadataFormatName, root)
return meta
}

/**
* Add testImplementation("io.github.darkxanter:webp-imageio:0.3.0") to use this
*/
fun losslessWebPSaver(): AwtImageWriter =
AwtImageWriter { file, context, bufferedImage ->
val writer: ImageWriter =
ImageIO.getImageWritersByMIMEType("image/webp").next()
try {
val writeParam = WebPWriteParam(writer.getLocale())
writeParam.compressionMode = ImageWriteParam.MODE_EXPLICIT
writeParam.compressionType =
writeParam.getCompressionTypes()
.get(WebPWriteParam.LOSSLESS_COMPRESSION)


writer.setOutput(FileImageOutputStream(file))

writer.write(null, IIOImage(bufferedImage, null, null), writeParam)
} catch (e: NoClassDefFoundError) {
throw IllegalStateException("Add testImplementation(\"io.github.darkxanter:webp-imageio:0.3.0\") to use this")
} finally {
writer.dispose()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ data class RoborazziOptions(
val resizeScale: Double = roborazziDefaultResizeScale(),
val applyDeviceCrop: Boolean = false,
val pixelBitConfig: PixelBitConfig = PixelBitConfig.Argb8888,
val platformRecordOptions: PlatformRecordOptions = JvmPlatformRecordOptions(),
)

enum class PixelBitConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ fun processOutputImageAndReport(
.save(
path = comparisonFile.absolutePath,
resizeScale = resizeScale,
contextData = contextData
contextData = contextData,
platformRecordOptions = recordOptions.platformRecordOptions,
)
debugLog {
"processOutputImageAndReport(): compareCanvas is saved " +
Expand All @@ -140,7 +141,8 @@ fun processOutputImageAndReport(
.save(
path = actualFile.absolutePath,
resizeScale = resizeScale,
contextData = contextData
contextData = contextData,
platformRecordOptions = recordOptions.platformRecordOptions,
)
debugLog {
"processOutputImageAndReport(): actualCanvas is saved " +
Expand Down Expand Up @@ -186,7 +188,8 @@ fun processOutputImageAndReport(
newRoboCanvas.save(
path = goldenFile.absolutePath,
resizeScale = resizeScale,
contextData = contextData
contextData = contextData,
platformRecordOptions = recordOptions.platformRecordOptions,
)
debugLog {
"processOutputImageAndReport: \n" +
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.github.takahirom.roborazzi

interface PlatformRecordOptions
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface RoboCanvas {
path: String,
resizeScale: Double,
contextData: Map<String, Any>,
platformRecordOptions: PlatformRecordOptions,
)
fun differ(other: RoboCanvas, resizeScale: Double, imageComparator: ImageComparator): ImageComparator.ComparisonResult
fun release()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ fun processOutputImageAndReportWithDefaults(
)
},
canvasFactoryFromFile = { file, bufferedImageType ->
AwtRoboCanvas.load(file, bufferedImageType)
AwtRoboCanvas.load(
file = file,
bufferedImageType = bufferedImageType,
platformRecordOptions = roborazziOptions.recordOptions.platformRecordOptions
)
},
comparisonCanvasFactory = { goldenCanvas, actualCanvas, resizeScale, bufferedImageType ->
AwtRoboCanvas.generateCompareCanvas(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,7 @@ import java.awt.font.TextLayout
import java.awt.geom.AffineTransform
import java.awt.image.AffineTransformOp
import java.awt.image.BufferedImage
import java.awt.image.RenderedImage
import java.io.File
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageTypeSpecifier
import javax.imageio.ImageWriter
import javax.imageio.metadata.IIOMetadata
import javax.imageio.metadata.IIOMetadataFormatImpl
import javax.imageio.metadata.IIOMetadataNode

class AwtRoboCanvas(width: Int, height: Int, filled: Boolean, bufferedImageType: Int) : RoboCanvas {
private val bufferedImage = BufferedImage(width, height, bufferedImageType)
Expand Down Expand Up @@ -219,7 +211,12 @@ class AwtRoboCanvas(width: Int, height: Int, filled: Boolean, bufferedImageType:
pendingDrawList.add(pendingDraw)
}

override fun save(path: String, resizeScale: Double, contextData: Map<String, Any>) {
override fun save(
path: String,
resizeScale: Double,
contextData: Map<String, Any>,
platformRecordOptions: PlatformRecordOptions
) {
val file = File(path)
drawPendingDraw()
val directory = file.parentFile
Expand All @@ -231,51 +228,12 @@ class AwtRoboCanvas(width: Int, height: Int, filled: Boolean, bufferedImageType:
// ignore
}
val scaledBufferedImage = croppedImage.scale(resizeScale)
val imageExtension = file.extension.ifBlank { "png" }
if (contextData.isEmpty()) {
ImageIO.write(
scaledBufferedImage,
imageExtension,
file
(platformRecordOptions as JvmPlatformRecordOptions)
.awtImageWriter.write(
dest = file,
contextData = contextData,
bufferedImage = scaledBufferedImage
)
return
}
val writer = getWriter(croppedImage, imageExtension)
val meta = writer.writeMetadata(contextData)
writer.output = ImageIO.createImageOutputStream(file)
writer.write(IIOImage(scaledBufferedImage, null, meta))
}

private fun ImageWriter.writeMetadata(
contextData: Map<String, Any>
): IIOMetadata? {
val meta = getDefaultImageMetadata(ImageTypeSpecifier(croppedImage), null) ?: run {
// If we use WebP, it seems that we can't get the metadata
return null
}

val root = IIOMetadataNode(IIOMetadataFormatImpl.standardMetadataFormatName)
contextData.forEach { (key, value) ->
val textEntry = IIOMetadataNode("TextEntry")
textEntry.setAttribute("keyword", key)
textEntry.setAttribute("value", value.toString())
val text = IIOMetadataNode("Text")
text.appendChild(textEntry)
root.appendChild(text)
}

meta.mergeTree(IIOMetadataFormatImpl.standardMetadataFormatName, root)
return meta
}

private fun getWriter(renderedImage: RenderedImage, extension: String): ImageWriter {
val typeSpecifier = ImageTypeSpecifier.createFromRenderedImage(renderedImage)
val iterator: Iterator<*> = ImageIO.getImageWriters(typeSpecifier, extension)
return if (iterator.hasNext()) {
iterator.next() as ImageWriter
} else {
throw IllegalArgumentException("No ImageWriter found for $extension")
}
}

override fun differ(
Expand Down Expand Up @@ -312,8 +270,8 @@ class AwtRoboCanvas(width: Int, height: Int, filled: Boolean, bufferedImageType:
}

companion object {
fun load(file: File, bufferedImageType: Int): AwtRoboCanvas {
val loadedImage: BufferedImage = ImageIO.read(file)
fun load(file: File, bufferedImageType: Int, platformRecordOptions: PlatformRecordOptions): AwtRoboCanvas {
val loadedImage: BufferedImage = (platformRecordOptions as JvmPlatformRecordOptions).awtImageLoader.load(file)
val awtRoboCanvas = AwtRoboCanvas(
loadedImage.width,
height = loadedImage.height,
Expand Down Expand Up @@ -599,3 +557,4 @@ private fun <T> BufferedImage.graphics(block: (Graphics2D) -> T): T {
graphics.dispose()
return result
}

1 change: 0 additions & 1 deletion roborazzi/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ dependencies {
compileOnly libs.androidx.compose.ui.test.junit4

compileOnly libs.robolectric
implementation libs.androidx.core.ktx
api libs.dropbox.differ

api libs.androidx.test.espresso.core
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,22 @@ import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.core.view.drawToBitmap
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.*
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onIdle
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoActivityResumedException
import androidx.test.espresso.Root
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.base.RootsOracle_Factory
import androidx.test.platform.app.InstrumentationRegistry
import com.dropbox.differ.ImageComparator
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.core.IsEqual
import java.io.File
import java.util.*
import java.util.Locale


fun ViewInteraction.captureRoboImage(
Expand Down Expand Up @@ -644,7 +649,7 @@ fun processOutputImageAndReportWithDefaults(
)
},
canvasFactoryFromFile = { file, bufferedImageType ->
AwtRoboCanvas.load(file, bufferedImageType)
AwtRoboCanvas.load(file, bufferedImageType, roborazziOptions.recordOptions.platformRecordOptions)
},
comparisonCanvasFactory = { goldenCanvas, actualCanvas, resizeScale, bufferedImageType ->
AwtRoboCanvas.generateCompareCanvas(
Expand Down
1 change: 1 addition & 0 deletions sample-android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ dependencies {
testImplementation libs.androidx.compose.ui.test.junit4
debugImplementation libs.androidx.compose.ui.test.manifest
implementation libs.androidx.activity.compose
testImplementation(libs.webp.imageio)

implementation libs.androidx.core.ktx
implementation libs.androidx.appcompat
Expand Down
Loading

0 comments on commit aab7c28

Please sign in to comment.