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}") + } +} 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..48f40b26 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/preview/HtmlToBitmapRenderer.kt @@ -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 { 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; })" + } +}