From 103a05903004d8e59aa6157943cc2c958b63532a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:00:28 -0600 Subject: [PATCH 1/4] feat(android): add HTML-to-Bitmap renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds HtmlToBitmapRenderer, an off-screen WebView wrapper that loads an HTML string and snapshots the laid-out content to a Bitmap. Mirrors the iOS HTMLWebViewRenderer MVP: no caching, no pooling — each call creates and destroys its own WebView. A follow-up will layer caching and WebView reuse on top. All WebView interaction is marshalled onto the main thread internally, so callers can invoke the suspending API from any dispatcher. --- .../gutenberg/preview/HtmlToBitmapRenderer.kt | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt new file mode 100644 index 00000000..c45345c4 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt @@ -0,0 +1,143 @@ +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.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 + } + + 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) + + val contentHeightCssPx = fetchContentHeightCssPx(webView) + val webViewHeightPx = max(1, (contentHeightCssPx * density).toInt()) + measureAndLayout(webView, webViewWidthPx, webViewHeightPx) + + 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 { 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) + } + } + + 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" + } +} From 061d614c97eca38d86eee0d29c9e308f67f48482 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:07:17 -0600 Subject: [PATCH 2/4] feat(android): await image loads before snapshotting pattern HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After onPageFinished, poll Array.from(document.images).every(i => i.complete) at 50ms intervals up to a 4s soft timeout. Catches the common case where tags with srcset, lazy decoding, or async DOM insertion are still in flight when window.load fires. On timeout, proceed with whatever has loaded rather than fail the render — a partial thumbnail is better than none. Same race exists on iOS; identical JS would close it there. --- .../gutenberg/preview/HtmlToBitmapRenderer.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt index c45345c4..2384b4ee 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt @@ -9,6 +9,7 @@ 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 @@ -73,6 +74,7 @@ internal class HtmlToBitmapRenderer( // 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()) @@ -111,6 +113,29 @@ internal class HtmlToBitmapRenderer( } } + /** + * `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, @@ -139,5 +164,9 @@ internal class HtmlToBitmapRenderer( 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 IMAGES_COMPLETE_JS = + "Array.from(document.images).every(function(i) { return i.complete; })" } } From e9422326ac5ca4f0b687b0ed582b22e33533d4fc Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:10:38 -0600 Subject: [PATCH 3/4] test(android): cover HtmlToBitmapRenderer with instrumented tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds JUnit instrumented tests that exercise the renderer against a real Android WebView on an emulator or device — the unit-test layer can't stand in for this because Robolectric's WebView shadow doesn't raster. Runs in CI via the `:android: Test Android Library Instrumented` step on the `mac-metal` queue. Each test writes its rendered PNG under the app's external cache dir and logs the absolute path under the `GBKRendererTest` tag so failures can be inspected with `adb pull` + an image viewer. --- .../HtmlToBitmapRendererInstrumentedTest.kt | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/preview/HtmlToBitmapRendererInstrumentedTest.kt diff --git a/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/preview/HtmlToBitmapRendererInstrumentedTest.kt b/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/preview/HtmlToBitmapRendererInstrumentedTest.kt new file mode 100644 index 00000000..e310aeab --- /dev/null +++ b/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/preview/HtmlToBitmapRendererInstrumentedTest.kt @@ -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 `/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 = """ + + +
+ + """.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 = """ + + + + +
Welcome to GutenbergKit
+
+

This is a block pattern preview rendered by HtmlToBitmapRenderer.

+
Card one.
+
Card two.
+
+ + + """.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 = """ + + + + + """.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}") + } +} From fc4fe1876b6829aa150185b77f28f9990c4fb040 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:37:57 -0600 Subject: [PATCH 4/4] fix(android): force software layer + settle delay for off-screen renders Without a parent window, Chromium-backed WebViews render into a GPU texture that WebView.draw(canvas) can't reach, producing blank bitmaps for CSS-only content. Force LAYER_TYPE_SOFTWARE and add a short post-layout settle so the software layer actually paints before pixels are read. --- .../wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt index 2384b4ee..48f40b26 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt @@ -67,6 +67,10 @@ internal class HtmlToBitmapRenderer( 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 { @@ -79,6 +83,10 @@ internal class HtmlToBitmapRenderer( 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 { @@ -166,6 +174,7 @@ internal class HtmlToBitmapRenderer( 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; })" }