Skip to content

Commit

Permalink
Replace delta text labels with pre-rendered bitmaps to sidestep cross…
Browse files Browse the repository at this point in the history
…-platform text rendering issues (#1496)
  • Loading branch information
jrodbx authored Jul 12, 2024
1 parent 3d0923f commit 5de870a
Show file tree
Hide file tree
Showing 4 changed files with 35 additions and 16 deletions.
39 changes: 30 additions & 9 deletions paparazzi/src/main/java/app/cash/paparazzi/internal/ImageUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,33 @@ internal object ImageUtils {
}

if (error != null) {
val deltaWidth = max(goldenImageWidth, imageWidth)
if (deltaWidth > 80) {
/**
* AWT uses native text rendering under the hood, making it extremely difficult to get
* consistent cross-platform label text rendering, due to antialiasing, etc. This can
* result in false negatives when comparing delta images.
*
* As a workaround, we instead use text images pre-rendered on MacOSX 14 with the default
* font=Dialog, size=12 and composite them into the delta image here.
*
* We use that original font's ascent to offset the labels, which is determined by running
* the following on MacOSX 14:
*
* ```
* val z = BufferedImage(1, 1, TYPE_INT_ARGB)
* val MAC_OSX_FONT_DIALOG_SIZE_12_ASCENT = z.graphics.fontMetrics.ascent
* ```
*/
val g = deltaImage.graphics
val yOffset = 20 - MAC_OSX_FONT_DIALOG_SIZE_12_ASCENT
val myClassLoader = ImageUtils::class.java.classLoader!!
val expectedLabel = ImageIO.read(myClassLoader.getResourceAsStream("expected_label.png"))
g.drawImage(expectedLabel, 10, yOffset, null)
val actualLabel = ImageIO.read(myClassLoader.getResourceAsStream("actual_label.png"))
g.drawImage(actualLabel, goldenImageWidth + deltaWidth + 10, yOffset, null)
}

val deltaOutput = File(failureDir, "delta-$imageName")
if (deltaOutput.exists()) {
val deleted = deltaOutput.delete()
Expand Down Expand Up @@ -98,8 +125,7 @@ internal object ImageUtils {
@Throws(IOException::class)
fun compareImages(
goldenImage: BufferedImage,
image: BufferedImage,
withText: Boolean = true
image: BufferedImage
): Pair<BufferedImage, Float> {
var goldenImage = goldenImage
if (goldenImage.type != TYPE_INT_ARGB) {
Expand Down Expand Up @@ -174,13 +200,6 @@ internal object ImageUtils {
g.drawImage(goldenImage, 0, 0, null)
g.drawImage(image, goldenImageWidth + deltaWidth, 0, null)

// Labels
if (withText && deltaWidth > 80) {
g.color = Color.RED
g.drawString("Expected", 10, 20)
g.drawString("Actual", goldenImageWidth + deltaWidth + 10, 20)
}

g.dispose()

// 3 different colors, 256 color levels
Expand Down Expand Up @@ -346,3 +365,5 @@ internal object ImageUtils {
return relativePath.substring(relativePath.lastIndexOf(separatorChar) + 1)
}
}

private const val MAC_OSX_FONT_DIALOG_SIZE_12_ASCENT: Int = 12
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ internal class ApngVerifier(

fun verifyFrame(image: BufferedImage) {
val (expectedFrame, actualFrame) = resizeMaxBounds(currentGoldenFrame ?: blankFrame, image)
val (deltaImage, percentDifferent) = ImageUtils.compareImages(expectedFrame, actualFrame, withErrorText)
val (deltaImage, percentDifferent) = ImageUtils.compareImages(expectedFrame, actualFrame)
if (percentDifferent > maxPercentDifference) {
if (deltaWriter == null) {
deltaWriter = pngReader.initializeWriter()
Expand All @@ -79,7 +79,7 @@ internal class ApngVerifier(
if (writer.frameCount % expectedDeltasPerFrame == 0) {
currentGoldenFrame = pngReader.readNextFrame()
val (expectedFrame, actualFrame) = resizeMaxBounds(currentGoldenFrame ?: blankFrame, image)
currentDelta = ImageUtils.compareImages(expectedFrame, actualFrame, withErrorText).first
currentDelta = ImageUtils.compareImages(expectedFrame, actualFrame).first
}
}
}
Expand All @@ -88,7 +88,7 @@ internal class ApngVerifier(
val deltaWriter = deltaWriter ?: return
currentGoldenFrame?.let { lastFrame ->
val (expectedFrame, actualFrame) = resizeMaxBounds(lastFrame, blankFrame)
val (currentDelta) = ImageUtils.compareImages(expectedFrame, actualFrame, withErrorText)
val (currentDelta) = ImageUtils.compareImages(expectedFrame, actualFrame)

val times = expectedDeltasPerFrame - (deltaWriter.frameCount % expectedDeltasPerFrame)
repeat(times) { deltaWriter.writeImage(currentDelta) }
Expand All @@ -98,8 +98,7 @@ internal class ApngVerifier(
while (!pngReader.isFinished()) {
val (deltaImage) = ImageUtils.compareImages(
goldenImage = pngReader.readNextFrame()!!,
image = blankFrame,
withText = withErrorText
image = blankFrame
)
repeat(expectedDeltasPerFrame) { deltaWriter.writeImage(deltaImage) }
invalidFrames++
Expand Down Expand Up @@ -143,8 +142,7 @@ internal class ApngVerifier(
val nextFrame = readNextFrame() ?: blankFrame
val (deltaImage) = ImageUtils.compareImages(
goldenImage = nextFrame,
image = nextFrame,
withText = withErrorText
image = nextFrame
)

writeImage(deltaImage)
Expand Down
Binary file added paparazzi/src/main/resources/actual_label.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added paparazzi/src/main/resources/expected_label.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 5de870a

Please sign in to comment.