Switch Mechyrdia Sans font rendering from SVG to PNG
authorLanius Trolling <lanius@laniustrolling.dev>
Fri, 21 Mar 2025 10:04:17 +0000 (06:04 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Fri, 21 Mar 2025 10:04:17 +0000 (06:04 -0400)
src/main/kotlin/info/mechyrdia/lore/FontDrawing.kt
src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt
src/main/resources/static/init.js

index 7d57a64ecdb7f69578adacc24a2f33b63e48c3d6..e6acfde69a34d25053939a88b6bf9033586ffd18 100644 (file)
@@ -3,12 +3,8 @@ package info.mechyrdia.lore
 import com.jaredrummler.fontreader.truetype.FontFileReader
 import com.jaredrummler.fontreader.truetype.TTFFile
 import com.jaredrummler.fontreader.util.GlyphSequence
-import info.mechyrdia.concat
 import info.mechyrdia.data.FileStorage
 import info.mechyrdia.data.StoragePath
-import info.mechyrdia.data.XmlTagConsumer
-import info.mechyrdia.data.declaration
-import info.mechyrdia.data.root
 import info.mechyrdia.route.KeyedEnumSerializer
 import info.mechyrdia.yieldThread
 import kotlinx.coroutines.Dispatchers
@@ -17,15 +13,20 @@ import kotlinx.coroutines.withContext
 import kotlinx.serialization.Serializable
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
+import java.awt.AlphaComposite
+import java.awt.Color
 import java.awt.Font
-import java.awt.Shape
+import java.awt.RenderingHints
 import java.awt.geom.AffineTransform
 import java.awt.geom.GeneralPath
-import java.awt.geom.PathIterator
 import java.awt.image.BufferedImage
 import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
 import java.io.IOException
 import java.nio.IntBuffer
+import javax.imageio.ImageIO
+import kotlin.math.ceil
+import kotlin.math.roundToInt
 import kotlin.properties.ReadOnlyProperty
 
 private val FontsLogger: Logger = LoggerFactory.getLogger("info.mechyrdia.lore.FontsKt")
@@ -54,44 +55,13 @@ enum class TextAlignment {
 
 object TextAlignmentSerializer : KeyedEnumSerializer<TextAlignment>(TextAlignment.entries)
 
-data class SvgDoc(
-       val width: Double,
-       val height: Double,
-       val viewBoxX: Double,
-       val viewBoxY: Double,
-       val viewBoxW: Double,
-       val viewBoxH: Double,
-       val path: SvgPath
-)
-
-data class SvgPath(
-       val d: String,
-       val fillRule: String
-)
-
-fun <T, C : XmlTagConsumer<T>> C.svg(svgDoc: SvgDoc) = declaration(standalone = false)
-       .root(
-               "svg",
-               namespace = "http://www.w3.org/2000/svg",
-               attributes = mapOf(
-                       "width" to svgDoc.width.xmlValue,
-                       "height" to svgDoc.height.xmlValue,
-                       "viewBox" to listOf(
-                               svgDoc.viewBoxX,
-                               svgDoc.viewBoxY,
-                               svgDoc.viewBoxW,
-                               svgDoc.viewBoxH,
-                       ).concat(" ") { it.xmlValue }
-               )
-       ) { "path"(attributes = mapOf("d" to svgDoc.path.d, "fill-rule" to svgDoc.path.fillRule)) }
-
 object MechyrdiaSansFont {
-       suspend fun renderTextToSvg(text: String, bold: Boolean, italic: Boolean, align: TextAlignment): SvgDoc {
+       suspend fun renderTextToPng(text: String, bold: Boolean, italic: Boolean, align: TextAlignment): ByteArray {
                val (file, font) = getFont(bold, italic)
                
                return runInterruptible(Dispatchers.Default) {
                        val shape = layoutText(text, file, font, align)
-                       createSvgDocument(shape, 80.0 / file.unitsPerEm, 12.0)
+                       createPngImage(shape, 160.0 / file.unitsPerEm, 24.0)
                }
        }
        
@@ -192,7 +162,7 @@ object MechyrdiaSansFont {
                return widths.zip(glyphPositions) { width, pos -> width + pos[2] }.sum()
        }
        
-       private fun layoutText(text: String, file: TTFFile, font: Font, align: TextAlignment): Shape {
+       private fun layoutText(text: String, file: TTFFile, font: Font, align: TextAlignment): GeneralPath {
                val img = BufferedImage(256, 160, BufferedImage.TYPE_INT_ARGB)
                val g2d = img.createGraphics()
                try {
@@ -250,73 +220,34 @@ object MechyrdiaSansFont {
                }
        }
        
-       private fun createSvgDocument(shape: Shape, scale: Double, padding: Double = 0.0): SvgDoc {
+       private fun createPngImage(shape: GeneralPath, scale: Double, padding: Double = 0.0): ByteArray {
+               shape.transform(AffineTransform.getScaleInstance(scale, scale))
+               
                val viewBox = shape.bounds2D
-               val vBoxPad = padding / scale
-               val sizePad = padding * 2
+               val imageWidth = ceil(viewBox.width + padding * 2).roundToInt()
+               val imageHeight = ceil(viewBox.height + padding * 2).roundToInt()
                
-               val path = shape.calculateSvgPath()
+               shape.transform(AffineTransform.getTranslateInstance(padding - viewBox.minX, padding - viewBox.minY))
                
-               return SvgDoc(
-                       width = (viewBox.width * scale) + sizePad,
-                       height = (viewBox.height * scale) + sizePad,
-                       viewBoxX = viewBox.minX - vBoxPad,
-                       viewBoxY = viewBox.minY - vBoxPad,
-                       viewBoxW = viewBox.width + (vBoxPad * 2),
-                       viewBoxH = viewBox.height + (vBoxPad * 2),
-                       path = path
-               )
-       }
-       
-       private fun Shape.calculateSvgPath(): SvgPath {
-               val iterator = getPathIterator(null)
-               val d = buildString {
-                       val coords = DoubleArray(6)
-                       var isFirst = true
+               val image = BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB)
+               
+               val g2d = image.createGraphics()
+               AutoCloseable(g2d::dispose).use { _ ->
+                       g2d.color = Color(0xFF, 0xFF, 0xFF, 0x00)
+                       g2d.composite = AlphaComposite.Src
+                       g2d.fillRect(0, 0, imageWidth, imageHeight)
                        
-                       while (!iterator.isDone) {
-                               if (isFirst)
-                                       isFirst = false
-                               else
-                                       append(' ')
-                               
-                               when (val segment = iterator.currentSegment(coords)) {
-                                       PathIterator.SEG_MOVETO -> {
-                                               append("M ${coords[0]},${coords[1]}")
-                                       }
-                                       
-                                       PathIterator.SEG_LINETO -> {
-                                               append("L ${coords[0]},${coords[1]}")
-                                       }
-                                       
-                                       PathIterator.SEG_QUADTO -> {
-                                               append("Q ${coords[0]},${coords[1]} ${coords[2]},${coords[3]}")
-                                       }
-                                       
-                                       PathIterator.SEG_CUBICTO -> {
-                                               append("C ${coords[0]},${coords[1]} ${coords[2]},${coords[3]} ${coords[4]},${coords[5]}")
-                                       }
-                                       
-                                       PathIterator.SEG_CLOSE -> {
-                                               append("Z")
-                                       }
-                                       
-                                       else -> error("Invalid segment type $segment")
-                               }
-                               
-                               iterator.next()
-                               
-                               yieldThread()
-                       }
+                       g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
+                       
+                       g2d.color = Color(0x00, 0x00, 0x00, 0xFF)
+                       g2d.composite = AlphaComposite.SrcOver
+                       g2d.fill(shape)
                }
                
-               val fillRule = when (val winding = iterator.windingRule) {
-                       PathIterator.WIND_EVEN_ODD -> "evenodd"
-                       PathIterator.WIND_NON_ZERO -> "nonzero"
-                       else -> error("Invalid winding rule $winding")
-               }
+               val output = ByteArrayOutputStream()
+               ImageIO.write(image, "PNG", output)
                
-               return SvgPath(d, fillRule)
+               return output.toByteArray()
        }
 }
 
index 88988953da8339050729f4df4c58af825d9b9d64..9ccca62d83d70154044c4e1c037479b49e5efd3e 100644 (file)
@@ -57,7 +57,6 @@ import info.mechyrdia.lore.redirectHref
 import info.mechyrdia.lore.respondAsset
 import info.mechyrdia.lore.respondRss
 import info.mechyrdia.lore.sitemap
-import info.mechyrdia.lore.svg
 import info.mechyrdia.lore.toCommentHtml
 import info.mechyrdia.lore.toFragmentString
 import info.mechyrdia.robot.RobotService
@@ -72,6 +71,7 @@ import io.ktor.server.application.ApplicationCall
 import io.ktor.server.html.respondHtml
 import io.ktor.server.plugins.MissingRequestParameterException
 import io.ktor.server.response.header
+import io.ktor.server.response.respondBytes
 import io.ktor.server.response.respondText
 import io.ktor.server.response.respondTextWriter
 import io.ktor.server.routing.RoutingContext
@@ -584,10 +584,8 @@ class Root : ResourceHandler, ResourceFilter {
                        override suspend fun RoutingContext.handleCall(payload: MechyrdiaSansPayload) {
                                with(utils) { call.filterCall() }
                                
-                               val svgDoc = MechyrdiaSansFont.renderTextToSvg(payload.lines.concat("\n") { it.trim() }, payload.bold, payload.italic, payload.align)
-                               call.respondXml(contentType = ContentType.Image.SVG) {
-                                       svg(svgDoc)
-                               }
+                               val pngBytes = MechyrdiaSansFont.renderTextToPng(payload.lines.concat("\n") { it.trim() }, payload.bold, payload.italic, payload.align)
+                               call.respondBytes(pngBytes, contentType = ContentType.Image.PNG)
                        }
                }
                
index 105e1cefcfe38fae38aee9658b3ff7d8a3f4e678..b40089a2e04f1029e977d7a50e244cd6b250e165 100644 (file)
        function appendWithLineBreaks(element, lines) {
                let isFirst = true;
                for (const line of lines) {
-                       if (isFirst)
+                       if (isFirst) {
                                isFirst = false;
-                       else
+                       } else {
                                element.append(document.createElement("br"));
+                       }
                        element.append(line);
                }
        }
                (function () {
                        // Mechyrdian font
 
+                       const blank1x1Png = new Uint8Array([
+                               0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
+                               0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
+                               0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xff, 0xff, 0x3f,
+                               0x03, 0x00, 0x08, 0xfc, 0x02, 0xfe, 0xa7, 0x9a, 0xa0, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
+                               0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
+                       ]).buffer;
+
                        /**
                         * @param {HTMLInputElement} input
                         * @param {HTMLInputElement} boldOpt
 
                                let outBlob;
                                if (inText.trim().length === 0) {
-                                       outBlob = new Blob([
-                                               "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
-                                               "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"0\" height=\"0\">\n",
-                                               "</svg>\n"
-                                       ], {type: "image/svg+xml"});
+                                       outBlob = new Blob([blank1x1Png], {type: "image/png"});
                                } else {
                                        const urlParams = new URLSearchParams();
                                        if (boldOpt.checked) urlParams.set("bold", "true");
                                        if (italicOpt.checked) urlParams.set("italic", "true");
                                        urlParams.set("align", alignOpt.value);
 
-                                       for (const line of inText.split("\n"))
+                                       for (const line of inText.split("\n")) {
                                                urlParams.append("lines", line.trim());
+                                       }
 
                                        outBlob = await (await fetch("/utils/mechyrdia-sans", {
                                                method: "POST",
                                }
 
                                const prevObjectUrl = output.src;
-                               if (prevObjectUrl != null && prevObjectUrl.length > 0)
+                               if (prevObjectUrl != null && prevObjectUrl.length > 0) {
                                        URL.revokeObjectURL(prevObjectUrl);
+                               }
 
                                output.src = URL.createObjectURL(outBlob);
                        }
                                const inText = input.value;
 
                                const urlParams = new URLSearchParams();
-                               for (const line of inText.split("\n"))
+                               for (const line of inText.split("\n")) {
                                        urlParams.append("lines", line.trim());
+                               }
 
                                const outText = await (await fetch("/utils/tylan-lang", {
                                        method: "POST",
                                        body: urlParams,
                                })).text();
 
-                               if (inText === input.value)
+                               if (inText === input.value) {
                                        output.value = outText;
+                               }
                        }
 
                        const tylanAlphabetBoxes = dom.querySelectorAll("div.tylan-alphabet-box");
                                const inText = input.value;
 
                                const urlParams = new URLSearchParams();
-                               for (const line of inText.split("\n"))
+                               for (const line of inText.split("\n")) {
                                        urlParams.append("lines", line.trim());
+                               }
 
                                const outText = await (await fetch("/utils/pokhwal-lang", {
                                        method: "POST",
                                        body: urlParams,
                                })).text();
 
-                               if (inText === input.value)
+                               if (inText === input.value) {
                                        output.value = outText;
+                               }
                        }
 
                        const pokhwalAlphabetBoxes = dom.querySelectorAll("div.pokhwal-alphabet-box");
                                const inText = input.value;
 
                                await delay(500);
-                               if (input.value !== inText)
+                               if (input.value !== inText) {
                                        return;
+                               }
 
                                if (inText.length === 0) {
                                        output.innerHTML = "";
                                }
 
                                const urlParams = new URLSearchParams();
-                               for (const line of inText.split("\n"))
+                               for (const line of inText.split("\n")) {
                                        urlParams.append("lines", line.trim());
+                               }
 
                                const outText = await (await fetch("/utils/preview-comment", {
                                        method: "POST",
                                        },
                                        body: urlParams,
                                })).text();
-                               if (input.value !== inText)
+                               if (input.value !== inText) {
                                        return;
+                               }
 
                                output.innerHTML = "<h3>Preview:</h3>" + outText;
                        }
                                        e.preventDefault();
 
                                        const thisElement = e.currentTarget;
-                                       if (thisElement.hasAttribute("data-copying"))
+                                       if (thisElement.hasAttribute("data-copying")) {
                                                return;
+                                       }
 
                                        const elementHtml = thisElement.innerHTML;
 
                                const redirectIdValue = window.location.hash;
                                const redirectIds = dom.querySelectorAll("h1[data-redirect-id], h2[data-redirect-id], h3[data-redirect-id], h4[data-redirect-id], h5[data-redirect-id], h6[data-redirect-id]");
                                for (const redirectId of redirectIds) {
-                                       if (redirectId.getAttribute("data-redirect-id") !== redirectIdValue)
+                                       if (redirectId.getAttribute("data-redirect-id") !== redirectIdValue) {
                                                continue;
+                                       }
 
                                        const pElement = document.createElement("p");
                                        pElement.style.fontSize = "0.8em";