Skip to content

Commit 6c2d3ea

Browse files
authored
feat(android): add HTML-to-Bitmap renderer (#470)
* feat(android): add HTML-to-Bitmap renderer 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. * feat(android): await image loads before snapshotting pattern HTML 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 <img> 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. * test(android): cover HtmlToBitmapRenderer with instrumented tests 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. * 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.
1 parent bfc85d9 commit 6c2d3ea

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package org.wordpress.gutenberg.preview
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.Color
5+
import android.util.Log
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.platform.app.InstrumentationRegistry
8+
import kotlinx.coroutines.runBlocking
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertTrue
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import java.io.File
14+
15+
/**
16+
* Instrumented tests that exercise [HtmlToBitmapRenderer] against a real Android
17+
* [android.webkit.WebView]. Runs in CI via the `:android: Test Android Library
18+
* Instrumented` step (`make test-android-library-e2e`); locally:
19+
*
20+
* ./gradlew :Gutenberg:connectedAndroidTest
21+
*
22+
* Each test writes its rendered PNG under `<externalCacheDir>/gbk-test-renders/`
23+
* and logs the absolute path at INFO level under the `GBKRendererTest` tag so you
24+
* can `adb pull` it for visual inspection.
25+
*/
26+
@RunWith(AndroidJUnit4::class)
27+
class HtmlToBitmapRendererInstrumentedTest {
28+
29+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
30+
private val renderer = HtmlToBitmapRenderer(context)
31+
32+
@Test
33+
fun rendersSolidColorBoxWithExpectedPixels() = runBlocking {
34+
val html = """
35+
<!doctype html>
36+
<html><body style="margin:0;padding:0;background:#00ff00">
37+
<div style="width:300px;height:200px"></div>
38+
</body></html>
39+
""".trimIndent()
40+
41+
val bitmap = renderer.render(
42+
html = html,
43+
viewportWidthCssPx = 300,
44+
maxOutputDimensionPx = 600,
45+
)
46+
47+
assertTrue("bitmap width > 0", bitmap.width > 0)
48+
assertTrue("bitmap height > 0", bitmap.height > 0)
49+
50+
val centerColor = bitmap.getPixel(bitmap.width / 2, bitmap.height / 2)
51+
assertEquals("center pixel should be pure green", Color.GREEN, centerColor)
52+
53+
writeDebugPng(bitmap, "solid-green.png")
54+
}
55+
56+
@Test
57+
fun rendersMultiBlockPatternLayout() = runBlocking {
58+
val html = """
59+
<!doctype html>
60+
<html>
61+
<head><style>
62+
body { margin: 0; font-family: sans-serif; background: #fff; }
63+
.header { background: #1e73be; color: white; padding: 24px;
64+
font-size: 32px; font-weight: bold; }
65+
.content { padding: 24px; color: #333; font-size: 16px; line-height: 1.5; }
66+
.card { background: #f5f5f5; padding: 16px; margin-top: 16px;
67+
border-radius: 8px; }
68+
</style></head>
69+
<body>
70+
<div class="header">Welcome to GutenbergKit</div>
71+
<div class="content">
72+
<p>This is a block pattern preview rendered by HtmlToBitmapRenderer.</p>
73+
<div class="card">Card one.</div>
74+
<div class="card">Card two.</div>
75+
</div>
76+
</body>
77+
</html>
78+
""".trimIndent()
79+
80+
val bitmap = renderer.render(
81+
html = html,
82+
viewportWidthCssPx = 1200,
83+
maxOutputDimensionPx = 1280,
84+
)
85+
86+
assertTrue("bitmap width > 0", bitmap.width > 0)
87+
assertTrue("bitmap height > 0", bitmap.height > 0)
88+
89+
writeDebugPng(bitmap, "multi-block-pattern.png")
90+
}
91+
92+
@Test
93+
fun rendersEmbeddedDataUriImage() = runBlocking {
94+
// 1x1 red pixel PNG, base64-encoded. Stretched to 200x200 for easy visual scan.
95+
val redPixel = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" +
96+
"AAAADUlEQVR4AWP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="
97+
val html = """
98+
<!doctype html>
99+
<html><body style="margin:0;padding:0;background:#ffffff">
100+
<img src="$redPixel" style="width:200px;height:200px;display:block" />
101+
</body></html>
102+
""".trimIndent()
103+
104+
val bitmap = renderer.render(
105+
html = html,
106+
viewportWidthCssPx = 200,
107+
maxOutputDimensionPx = 400,
108+
)
109+
110+
assertTrue("bitmap width > 0", bitmap.width > 0)
111+
assertTrue("bitmap height > 0", bitmap.height > 0)
112+
113+
writeDebugPng(bitmap, "data-uri-image.png")
114+
}
115+
116+
private fun writeDebugPng(bitmap: Bitmap, name: String) {
117+
val dir = File(context.externalCacheDir ?: context.cacheDir, "gbk-test-renders")
118+
dir.mkdirs()
119+
val out = File(dir, name)
120+
out.outputStream().use { stream ->
121+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
122+
}
123+
Log.i("GBKRendererTest", "Wrote debug render: ${out.absolutePath}")
124+
}
125+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package org.wordpress.gutenberg.preview
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import android.graphics.Bitmap
6+
import android.graphics.Canvas
7+
import android.graphics.Color
8+
import android.view.View
9+
import android.webkit.WebView
10+
import android.webkit.WebViewClient
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.delay
13+
import kotlinx.coroutines.suspendCancellableCoroutine
14+
import kotlinx.coroutines.withContext
15+
import kotlinx.coroutines.withTimeout
16+
import kotlin.coroutines.resume
17+
import kotlin.math.max
18+
import kotlin.math.min
19+
20+
/**
21+
* Renders an HTML string to a [Bitmap] by loading it into an off-screen [WebView]
22+
* and drawing the laid-out content onto a [Canvas].
23+
*
24+
* Mirrors the iOS `HTMLWebViewRenderer` MVP: no caching, no pooling — each call
25+
* creates and destroys its own WebView. Follow-ups will add a disk/memory cache
26+
* and reuse WebView instances across calls.
27+
*
28+
* All WebView interaction happens on the main thread. The suspending API can be
29+
* called from any dispatcher; work is marshalled onto [Dispatchers.Main] internally.
30+
*/
31+
internal class HtmlToBitmapRenderer(
32+
private val context: Context,
33+
private val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
34+
) {
35+
36+
/**
37+
* Load [html] off-screen and return a [Bitmap] of the rendered content.
38+
*
39+
* @param viewportWidthCssPx CSS viewport width the HTML should lay out against.
40+
* Block patterns typically declare 1200.
41+
* @param maxOutputDimensionPx Upper bound for either dimension of the returned
42+
* bitmap, in device pixels. The bitmap is never upscaled — if the rendered
43+
* content is already smaller it is returned at its native size.
44+
*/
45+
suspend fun render(
46+
html: String,
47+
viewportWidthCssPx: Int,
48+
maxOutputDimensionPx: Int,
49+
): Bitmap = withContext(Dispatchers.Main) {
50+
withTimeout(timeoutMs) {
51+
renderOnMainThread(html, viewportWidthCssPx, maxOutputDimensionPx)
52+
}
53+
}
54+
55+
@SuppressLint("SetJavaScriptEnabled")
56+
private suspend fun renderOnMainThread(
57+
html: String,
58+
viewportWidthCssPx: Int,
59+
maxOutputDimensionPx: Int,
60+
): Bitmap {
61+
val density = context.resources.displayMetrics.density
62+
val webViewWidthPx = max(1, (viewportWidthCssPx * density).toInt())
63+
64+
val webView = WebView(context).apply {
65+
settings.javaScriptEnabled = true
66+
settings.useWideViewPort = false
67+
settings.loadWithOverviewMode = false
68+
isHorizontalScrollBarEnabled = false
69+
isVerticalScrollBarEnabled = false
70+
// Force software rendering so WebView.draw() captures pixels onto a
71+
// software Canvas. Without this, Chromium-backed WebViews render into
72+
// a GPU texture that isn't reachable from an off-screen draw call.
73+
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
74+
}
75+
76+
return try {
77+
// Start with a tiny height so document.documentElement.scrollHeight
78+
// reflects actual content height rather than the viewport height.
79+
measureAndLayout(webView, webViewWidthPx, max(1, (INITIAL_HEIGHT_DP * density).toInt()))
80+
loadHtmlAwaitFinish(webView, html)
81+
awaitImagesLoaded(webView)
82+
83+
val contentHeightCssPx = fetchContentHeightCssPx(webView)
84+
val webViewHeightPx = max(1, (contentHeightCssPx * density).toInt())
85+
measureAndLayout(webView, webViewWidthPx, webViewHeightPx)
86+
// After resizing, give the software layer a chance to repaint before we
87+
// read pixels. Without a parent window there's no invalidation cycle, so
88+
// an explicit settle delay is the pragmatic way to avoid a blank frame.
89+
delay(POST_LAYOUT_SETTLE_MS)
90+
91+
drawToBitmap(webView, webViewWidthPx, webViewHeightPx, maxOutputDimensionPx)
92+
} finally {
93+
webView.stopLoading()
94+
webView.destroy()
95+
}
96+
}
97+
98+
private fun measureAndLayout(view: View, widthPx: Int, heightPx: Int) {
99+
val widthSpec = View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY)
100+
val heightSpec = View.MeasureSpec.makeMeasureSpec(heightPx, View.MeasureSpec.EXACTLY)
101+
view.measure(widthSpec, heightSpec)
102+
view.layout(0, 0, widthPx, heightPx)
103+
}
104+
105+
private suspend fun loadHtmlAwaitFinish(webView: WebView, html: String) {
106+
suspendCancellableCoroutine<Unit> { cont ->
107+
webView.webViewClient = object : WebViewClient() {
108+
override fun onPageFinished(view: WebView?, url: String?) {
109+
if (cont.isActive) cont.resume(Unit)
110+
}
111+
}
112+
webView.loadDataWithBaseURL(null, html, MIME_HTML, ENCODING_UTF8, null)
113+
}
114+
}
115+
116+
private suspend fun fetchContentHeightCssPx(webView: WebView): Float =
117+
suspendCancellableCoroutine { cont ->
118+
webView.evaluateJavascript("document.documentElement.scrollHeight") { value ->
119+
val height = value?.trim()?.trim('"')?.toFloatOrNull() ?: 0f
120+
if (cont.isActive) cont.resume(height)
121+
}
122+
}
123+
124+
/**
125+
* `onPageFinished` fires after `window.load`, but images added async, decoded from
126+
* `srcset`, or injected dynamically can still be in flight. Poll
127+
* `document.images.every(i => i.complete)` until true or until the soft timeout
128+
* expires, at which point we proceed with whatever has loaded rather than fail.
129+
*/
130+
private suspend fun awaitImagesLoaded(webView: WebView) {
131+
val deadline = System.currentTimeMillis() + IMAGES_TIMEOUT_MS
132+
while (true) {
133+
if (areAllImagesComplete(webView)) return
134+
if (System.currentTimeMillis() >= deadline) return
135+
delay(IMAGES_POLL_INTERVAL_MS)
136+
}
137+
}
138+
139+
private suspend fun areAllImagesComplete(webView: WebView): Boolean =
140+
suspendCancellableCoroutine { cont ->
141+
webView.evaluateJavascript(IMAGES_COMPLETE_JS) { value ->
142+
val done = value?.trim() == "true"
143+
if (cont.isActive) cont.resume(done)
144+
}
145+
}
146+
147+
private fun drawToBitmap(
148+
webView: WebView,
149+
widthPx: Int,
150+
heightPx: Int,
151+
maxOutputDimensionPx: Int,
152+
): Bitmap {
153+
val widthScale = maxOutputDimensionPx.toFloat() / widthPx
154+
val heightScale = maxOutputDimensionPx.toFloat() / heightPx
155+
val scale = min(min(widthScale, heightScale), 1f)
156+
157+
val outputWidth = max(1, (widthPx * scale).toInt())
158+
val outputHeight = max(1, (heightPx * scale).toInt())
159+
160+
val bitmap = Bitmap.createBitmap(outputWidth, outputHeight, Bitmap.Config.ARGB_8888)
161+
val canvas = Canvas(bitmap)
162+
canvas.drawColor(Color.WHITE)
163+
if (scale < 1f) canvas.scale(scale, scale)
164+
webView.draw(canvas)
165+
return bitmap
166+
}
167+
168+
companion object {
169+
const val DEFAULT_VIEWPORT_WIDTH_CSS_PX = 1200
170+
const val DEFAULT_MAX_OUTPUT_DIMENSION_PX = 1280
171+
const val DEFAULT_TIMEOUT_MS = 16_000L
172+
private const val INITIAL_HEIGHT_DP = 80
173+
private const val MIME_HTML = "text/html"
174+
private const val ENCODING_UTF8 = "UTF-8"
175+
private const val IMAGES_POLL_INTERVAL_MS = 50L
176+
private const val IMAGES_TIMEOUT_MS = 4_000L
177+
private const val POST_LAYOUT_SETTLE_MS = 200L
178+
private const val IMAGES_COMPLETE_JS =
179+
"Array.from(document.images).every(function(i) { return i.complete; })"
180+
}
181+
}

0 commit comments

Comments
 (0)