Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package org.wordpress.gutenberg.preview

import android.graphics.Bitmap
import android.graphics.Color
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File

/**
* Instrumented tests that exercise [HtmlToBitmapRenderer] against a real Android
* [android.webkit.WebView]. Runs in CI via the `:android: Test Android Library
* Instrumented` step (`make test-android-library-e2e`); locally:
*
* ./gradlew :Gutenberg:connectedAndroidTest
*
* Each test writes its rendered PNG under `<externalCacheDir>/gbk-test-renders/`
* and logs the absolute path at INFO level under the `GBKRendererTest` tag so you
* can `adb pull` it for visual inspection.
*/
@RunWith(AndroidJUnit4::class)
class HtmlToBitmapRendererInstrumentedTest {

private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val renderer = HtmlToBitmapRenderer(context)

@Test
fun rendersSolidColorBoxWithExpectedPixels() = runBlocking {
val html = """
<!doctype html>
<html><body style="margin:0;padding:0;background:#00ff00">
<div style="width:300px;height:200px"></div>
</body></html>
""".trimIndent()

val bitmap = renderer.render(
html = html,
viewportWidthCssPx = 300,
maxOutputDimensionPx = 600,
)

assertTrue("bitmap width > 0", bitmap.width > 0)
assertTrue("bitmap height > 0", bitmap.height > 0)

val centerColor = bitmap.getPixel(bitmap.width / 2, bitmap.height / 2)
assertEquals("center pixel should be pure green", Color.GREEN, centerColor)

writeDebugPng(bitmap, "solid-green.png")
}

@Test
fun rendersMultiBlockPatternLayout() = runBlocking {
val html = """
<!doctype html>
<html>
<head><style>
body { margin: 0; font-family: sans-serif; background: #fff; }
.header { background: #1e73be; color: white; padding: 24px;
font-size: 32px; font-weight: bold; }
.content { padding: 24px; color: #333; font-size: 16px; line-height: 1.5; }
.card { background: #f5f5f5; padding: 16px; margin-top: 16px;
border-radius: 8px; }
</style></head>
<body>
<div class="header">Welcome to GutenbergKit</div>
<div class="content">
<p>This is a block pattern preview rendered by HtmlToBitmapRenderer.</p>
<div class="card">Card one.</div>
<div class="card">Card two.</div>
</div>
</body>
</html>
""".trimIndent()

val bitmap = renderer.render(
html = html,
viewportWidthCssPx = 1200,
maxOutputDimensionPx = 1280,
)

assertTrue("bitmap width > 0", bitmap.width > 0)
assertTrue("bitmap height > 0", bitmap.height > 0)

writeDebugPng(bitmap, "multi-block-pattern.png")
}

@Test
fun rendersEmbeddedDataUriImage() = runBlocking {
// 1x1 red pixel PNG, base64-encoded. Stretched to 200x200 for easy visual scan.
val redPixel = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" +
"AAAADUlEQVR4AWP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="
val html = """
<!doctype html>
<html><body style="margin:0;padding:0;background:#ffffff">
<img src="$redPixel" style="width:200px;height:200px;display:block" />
</body></html>
""".trimIndent()

val bitmap = renderer.render(
html = html,
viewportWidthCssPx = 200,
maxOutputDimensionPx = 400,
)

assertTrue("bitmap width > 0", bitmap.width > 0)
assertTrue("bitmap height > 0", bitmap.height > 0)

writeDebugPng(bitmap, "data-uri-image.png")
}

private fun writeDebugPng(bitmap: Bitmap, name: String) {
val dir = File(context.externalCacheDir ?: context.cacheDir, "gbk-test-renders")
dir.mkdirs()
val out = File(dir, name)
out.outputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
Log.i("GBKRendererTest", "Wrote debug render: ${out.absolutePath}")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package org.wordpress.gutenberg.preview

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume
import kotlin.math.max
import kotlin.math.min

/**
* Renders an HTML string to a [Bitmap] by loading it into an off-screen [WebView]
* and drawing the laid-out content onto a [Canvas].
*
* Mirrors the iOS `HTMLWebViewRenderer` MVP: no caching, no pooling — each call
* creates and destroys its own WebView. Follow-ups will add a disk/memory cache
* and reuse WebView instances across calls.
*
* All WebView interaction happens on the main thread. The suspending API can be
* called from any dispatcher; work is marshalled onto [Dispatchers.Main] internally.
*/
internal class HtmlToBitmapRenderer(
private val context: Context,
private val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
) {

/**
* Load [html] off-screen and return a [Bitmap] of the rendered content.
*
* @param viewportWidthCssPx CSS viewport width the HTML should lay out against.
* Block patterns typically declare 1200.
* @param maxOutputDimensionPx Upper bound for either dimension of the returned
* bitmap, in device pixels. The bitmap is never upscaled — if the rendered
* content is already smaller it is returned at its native size.
*/
suspend fun render(
html: String,
viewportWidthCssPx: Int,
maxOutputDimensionPx: Int,
): Bitmap = withContext(Dispatchers.Main) {
withTimeout(timeoutMs) {
renderOnMainThread(html, viewportWidthCssPx, maxOutputDimensionPx)
}
}

@SuppressLint("SetJavaScriptEnabled")
private suspend fun renderOnMainThread(
html: String,
viewportWidthCssPx: Int,
maxOutputDimensionPx: Int,
): Bitmap {
val density = context.resources.displayMetrics.density
val webViewWidthPx = max(1, (viewportWidthCssPx * density).toInt())

val webView = WebView(context).apply {
settings.javaScriptEnabled = true
settings.useWideViewPort = false
settings.loadWithOverviewMode = false
isHorizontalScrollBarEnabled = false
isVerticalScrollBarEnabled = false
// Force software rendering so WebView.draw() captures pixels onto a
// software Canvas. Without this, Chromium-backed WebViews render into
// a GPU texture that isn't reachable from an off-screen draw call.
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
}

return try {
// Start with a tiny height so document.documentElement.scrollHeight
// reflects actual content height rather than the viewport height.
measureAndLayout(webView, webViewWidthPx, max(1, (INITIAL_HEIGHT_DP * density).toInt()))
loadHtmlAwaitFinish(webView, html)
awaitImagesLoaded(webView)

val contentHeightCssPx = fetchContentHeightCssPx(webView)
val webViewHeightPx = max(1, (contentHeightCssPx * density).toInt())
measureAndLayout(webView, webViewWidthPx, webViewHeightPx)
// After resizing, give the software layer a chance to repaint before we
// read pixels. Without a parent window there's no invalidation cycle, so
// an explicit settle delay is the pragmatic way to avoid a blank frame.
delay(POST_LAYOUT_SETTLE_MS)

drawToBitmap(webView, webViewWidthPx, webViewHeightPx, maxOutputDimensionPx)
} finally {
webView.stopLoading()
webView.destroy()
}
}

private fun measureAndLayout(view: View, widthPx: Int, heightPx: Int) {
val widthSpec = View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY)
val heightSpec = View.MeasureSpec.makeMeasureSpec(heightPx, View.MeasureSpec.EXACTLY)
view.measure(widthSpec, heightSpec)
view.layout(0, 0, widthPx, heightPx)
}

private suspend fun loadHtmlAwaitFinish(webView: WebView, html: String) {
suspendCancellableCoroutine<Unit> { cont ->
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
if (cont.isActive) cont.resume(Unit)
}
}
webView.loadDataWithBaseURL(null, html, MIME_HTML, ENCODING_UTF8, null)
}
}

private suspend fun fetchContentHeightCssPx(webView: WebView): Float =
suspendCancellableCoroutine { cont ->
webView.evaluateJavascript("document.documentElement.scrollHeight") { value ->
val height = value?.trim()?.trim('"')?.toFloatOrNull() ?: 0f
if (cont.isActive) cont.resume(height)
}
}

/**
* `onPageFinished` fires after `window.load`, but images added async, decoded from
* `srcset`, or injected dynamically can still be in flight. Poll
* `document.images.every(i => i.complete)` until true or until the soft timeout
* expires, at which point we proceed with whatever has loaded rather than fail.
*/
private suspend fun awaitImagesLoaded(webView: WebView) {
val deadline = System.currentTimeMillis() + IMAGES_TIMEOUT_MS
while (true) {
if (areAllImagesComplete(webView)) return
if (System.currentTimeMillis() >= deadline) return
delay(IMAGES_POLL_INTERVAL_MS)
}
}

private suspend fun areAllImagesComplete(webView: WebView): Boolean =
suspendCancellableCoroutine { cont ->
webView.evaluateJavascript(IMAGES_COMPLETE_JS) { value ->
val done = value?.trim() == "true"
if (cont.isActive) cont.resume(done)
}
}

private fun drawToBitmap(
webView: WebView,
widthPx: Int,
heightPx: Int,
maxOutputDimensionPx: Int,
): Bitmap {
val widthScale = maxOutputDimensionPx.toFloat() / widthPx
val heightScale = maxOutputDimensionPx.toFloat() / heightPx
val scale = min(min(widthScale, heightScale), 1f)

val outputWidth = max(1, (widthPx * scale).toInt())
val outputHeight = max(1, (heightPx * scale).toInt())

val bitmap = Bitmap.createBitmap(outputWidth, outputHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
if (scale < 1f) canvas.scale(scale, scale)
webView.draw(canvas)
return bitmap
}

companion object {
const val DEFAULT_VIEWPORT_WIDTH_CSS_PX = 1200
const val DEFAULT_MAX_OUTPUT_DIMENSION_PX = 1280
const val DEFAULT_TIMEOUT_MS = 16_000L
private const val INITIAL_HEIGHT_DP = 80
private const val MIME_HTML = "text/html"
private const val ENCODING_UTF8 = "UTF-8"
private const val IMAGES_POLL_INTERVAL_MS = 50L
private const val IMAGES_TIMEOUT_MS = 4_000L
private const val POST_LAYOUT_SETTLE_MS = 200L
private const val IMAGES_COMPLETE_JS =
"Array.from(document.images).every(function(i) { return i.complete; })"
}
}
Loading