From: Lanius Trolling Date: Fri, 21 Mar 2025 10:04:17 +0000 (-0400) Subject: Switch Mechyrdia Sans font rendering from SVG to PNG X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=51aff301aef2d7ae745ccd0af1923b9cd16b7df7;p=factbooks Switch Mechyrdia Sans font rendering from SVG to PNG --- diff --git a/src/main/kotlin/info/mechyrdia/lore/FontDrawing.kt b/src/main/kotlin/info/mechyrdia/lore/FontDrawing.kt index 7d57a64..e6acfde 100644 --- a/src/main/kotlin/info/mechyrdia/lore/FontDrawing.kt +++ b/src/main/kotlin/info/mechyrdia/lore/FontDrawing.kt @@ -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.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 > 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() } } diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt index 8898895..9ccca62 100644 --- a/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt +++ b/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt @@ -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) } } diff --git a/src/main/resources/static/init.js b/src/main/resources/static/init.js index 105e1ce..b40089a 100644 --- a/src/main/resources/static/init.js +++ b/src/main/resources/static/init.js @@ -312,10 +312,11 @@ 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); } } @@ -624,6 +625,14 @@ (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 @@ -641,19 +650,16 @@ let outBlob; if (inText.trim().length === 0) { - outBlob = new Blob([ - "\n", - "\n", - "\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", @@ -667,8 +673,9 @@ } const prevObjectUrl = output.src; - if (prevObjectUrl != null && prevObjectUrl.length > 0) + if (prevObjectUrl != null && prevObjectUrl.length > 0) { URL.revokeObjectURL(prevObjectUrl); + } output.src = URL.createObjectURL(outBlob); } @@ -702,8 +709,9 @@ 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", @@ -713,8 +721,9 @@ body: urlParams, })).text(); - if (inText === input.value) + if (inText === input.value) { output.value = outText; + } } const tylanAlphabetBoxes = dom.querySelectorAll("div.tylan-alphabet-box"); @@ -766,8 +775,9 @@ 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", @@ -777,8 +787,9 @@ body: urlParams, })).text(); - if (inText === input.value) + if (inText === input.value) { output.value = outText; + } } const pokhwalAlphabetBoxes = dom.querySelectorAll("div.pokhwal-alphabet-box"); @@ -1020,8 +1031,9 @@ const inText = input.value; await delay(500); - if (input.value !== inText) + if (input.value !== inText) { return; + } if (inText.length === 0) { output.innerHTML = ""; @@ -1029,8 +1041,9 @@ } 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", @@ -1039,8 +1052,9 @@ }, body: urlParams, })).text(); - if (input.value !== inText) + if (input.value !== inText) { return; + } output.innerHTML = "

Preview:

" + outText; } @@ -1088,8 +1102,9 @@ e.preventDefault(); const thisElement = e.currentTarget; - if (thisElement.hasAttribute("data-copying")) + if (thisElement.hasAttribute("data-copying")) { return; + } const elementHtml = thisElement.innerHTML; @@ -1190,8 +1205,9 @@ 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";