Add cross-platform canvas test and fix JS text rendering

This commit is contained in:
Alinson S. Xavier 2026-04-11 06:25:02 -05:00
parent aa9bd1082b
commit bb3440279e
6 changed files with 75 additions and 86 deletions

View File

@ -1,9 +1,33 @@
const path = require("path"); const path = require("path");
const fs = require("fs");
const migrationsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/migrations"); const migrationsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/migrations");
const testAssetsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/test"); const testAssetsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/test");
const fontsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/fonts"); const fontsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/fonts");
function saveFileMiddleware(req, res, next) {
if (req.url !== "/save-file" || req.method !== "POST") return next();
const filePath = req.headers["x-file-path"];
if (!filePath) {
res.writeHead(400);
res.end("Missing X-File-Path header");
return;
}
const chunks = [];
req.on("data", chunk => chunks.push(chunk));
req.on("end", () => {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, Buffer.concat(chunks));
res.writeHead(200);
res.end("OK");
});
}
config.set({ config.set({
beforeMiddleware: ["saveFile"],
plugins: (config.plugins || []).concat([
{ "middleware:saveFile": ["value", saveFileMiddleware] }
]),
browserNoActivityTimeout: 120000, browserNoActivityTimeout: 120000,
browserDisconnectTimeout: 30000, browserDisconnectTimeout: 30000,
browserDisconnectTolerance: 3, browserDisconnectTolerance: 3,

View File

@ -0,0 +1,16 @@
package org.isoron.platform.gui
import kotlinx.coroutines.test.runTest
import org.isoron.platform.io.createTestCanvas
import org.isoron.platform.io.ensureFontsLoaded
import kotlin.test.Test
class CanvasTest {
@Test
fun testDrawTestImage() = runTest {
ensureFontsLoaded()
val canvas = createTestCanvas(500, 400)
canvas.drawTestImage()
assertRenders("views/CanvasTest.png", canvas)
}
}

View File

@ -45,12 +45,15 @@ class JsCanvas(
val py = toPixel(y) val py = toPixel(y)
val metrics = ctx.measureText(text) val metrics = ctx.measureText(text)
val textWidth = metrics.width val textWidth = metrics.width
val ascent = metrics.asDynamic().actualBoundingBoxAscent as Double
val descent = metrics.asDynamic().actualBoundingBoxDescent as Double
val xPos = when (textAlign) { val xPos = when (textAlign) {
TextAlign.CENTER -> px - textWidth / 2 TextAlign.CENTER -> px - textWidth / 2
TextAlign.RIGHT -> px - textWidth TextAlign.RIGHT -> px - textWidth
TextAlign.LEFT -> px TextAlign.LEFT -> px
} }
ctx.fillText(text, xPos, py) val yPos = py + (ascent - descent) / 2
ctx.fillText(text, xPos, yPos)
} }
override fun fillRect(x: Double, y: Double, width: Double, height: Double) { override fun fillRect(x: Double, y: Double, width: Double, height: Double) {
@ -148,7 +151,6 @@ class JsCanvas(
} }
val weight = if (font == Font.BOLD) "bold" else "normal" val weight = if (font == Font.BOLD) "bold" else "normal"
ctx.font = "$weight ${sizePx}px $family" ctx.font = "$weight ${sizePx}px $family"
ctx.asDynamic().textBaseline = "middle"
} }
companion object { companion object {

View File

@ -1,8 +1,14 @@
package org.isoron.platform.gui package org.isoron.platform.gui
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.CanvasRenderingContext2D import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
import org.w3c.fetch.RequestInit
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.js.json
class JsImage(private val canvas: HTMLCanvasElement) : Image { class JsImage(private val canvas: HTMLCanvasElement) : Image {
private val ctx = canvas.getContext("2d") as CanvasRenderingContext2D private val ctx = canvas.getContext("2d") as CanvasRenderingContext2D
@ -31,7 +37,20 @@ class JsImage(private val canvas: HTMLCanvasElement) : Image {
} }
override suspend fun export(path: String) { override suspend fun export(path: String) {
// No-op on JS browser; images can't be exported to filesystem val blob = suspendCoroutine<dynamic> { cont ->
canvas.asDynamic().toBlob { b: dynamic -> cont.resume(b) }
}
val init = RequestInit(
method = "POST",
body = blob,
headers = json("X-File-Path" to path),
)
suspendCoroutine<Unit> { cont ->
window.fetch("/save-file", init).then(
{ cont.resume(Unit) },
{ err -> cont.resumeWithException(RuntimeException("$err")) }
)
}
} }
companion object { companion object {

View File

@ -6,6 +6,7 @@ import org.isoron.platform.gui.JsCanvas
import org.isoron.platform.time.JsLocalDateFormatter import org.isoron.platform.time.JsLocalDateFormatter
import org.isoron.platform.time.LocalDateFormatter import org.isoron.platform.time.LocalDateFormatter
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
val sharedTestStorage = JsFileStorage() val sharedTestStorage = JsFileStorage()
@ -48,10 +49,14 @@ actual suspend fun ensureFontsLoaded() {
fontsLoaded = true fontsLoaded = true
} }
private suspend fun loadFontAsync(fontSpec: String) = suspendCoroutine<Unit> { cont -> private suspend fun loadFontAsync(fontSpec: String) {
val promise = document.asDynamic().fonts.load(fontSpec) suspendCoroutine<Unit> { cont ->
promise.then( val promise = document.asDynamic().fonts.load(fontSpec)
{ _: dynamic -> cont.resume(Unit) }, promise.then(
{ _: dynamic -> cont.resume(Unit) } { _: dynamic -> cont.resume(Unit) },
) { err: dynamic -> cont.resumeWithException(RuntimeException("Failed to load font '$fontSpec': $err")) }
)
}
val loaded = document.asDynamic().fonts.check(fontSpec) as Boolean
if (!loaded) error("Font not available after loading: '$fontSpec'")
} }

View File

@ -1,77 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.JavaFileOpener
import org.junit.Assert.fail
import org.junit.Test
import java.awt.image.BufferedImage
import java.awt.image.BufferedImage.TYPE_INT_ARGB
class JavaCanvasTest {
@Test
fun run() = runBlocking {
assertRenders("views/CanvasTest.png", createCanvas(500, 400).apply { drawTestImage() })
}
}
private fun createCanvas(w: Int, h: Int) = JavaCanvas(BufferedImage(2 * w, 2 * h, TYPE_INT_ARGB), 2.0)
private suspend fun assertRenders(
path: String,
canvas: Canvas
) {
val actualImage = canvas.toImage()
val failedActualPath = "/tmp/failed/$path"
val failedExpectedPath = failedActualPath.replace(
".png",
".expected.png"
)
val failedDiffPath = failedActualPath.replace(".png", ".diff.png")
val fileOpener = JavaFileOpener()
val expectedFile = fileOpener.openResourceFile(path)
if (expectedFile.exists()) {
val expectedImage = expectedFile.toImage()
val diffImage = expectedFile.toImage()
diffImage.diff(actualImage)
val distance = diffImage.averageLuminosity * 100
if (distance >= 1.0) {
expectedImage.export(failedExpectedPath)
actualImage.export(failedActualPath)
diffImage.export(failedDiffPath)
fail("Images differ (distance=$distance)")
}
} else {
actualImage.export(failedActualPath)
fail("Expected image file is missing. Actual image: $failedActualPath")
}
}
private suspend fun assertRenders(
width: Int,
height: Int,
expectedPath: String,
view: View
) {
val canvas = createCanvas(width, height)
view.draw(canvas)
assertRenders(expectedPath, canvas)
}