Improve SVG-handling code
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 14 Apr 2024 12:39:19 +0000 (08:39 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 14 Apr 2024 12:39:19 +0000 (08:39 -0400)
src/jvmMain/kotlin/info/mechyrdia/lore/Fonts.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt [deleted file]

diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/Fonts.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/Fonts.kt
new file mode 100644 (file)
index 0000000..9c36df5
--- /dev/null
@@ -0,0 +1,441 @@
+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.data.*
+import info.mechyrdia.route.KeyedEnumSerializer
+import info.mechyrdia.yieldThread
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.awt.Font
+import java.awt.Shape
+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.IOException
+import java.nio.IntBuffer
+import kotlin.properties.ReadOnlyProperty
+
+@Serializable(with = TextAlignmentSerializer::class)
+enum class TextAlignment {
+       LEFT {
+               override fun processWidth(widthDiff: Int): Int {
+                       return 0
+               }
+       },
+       CENTER {
+               override fun processWidth(widthDiff: Int): Int {
+                       return widthDiff / 2
+               }
+       },
+       RIGHT {
+               override fun processWidth(widthDiff: Int): Int {
+                       return widthDiff
+               }
+       };
+       
+       abstract fun processWidth(widthDiff: Int): Int
+}
+
+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)
+       .defaultXmlns("http://www.w3.org/2000/svg")
+       .root(
+               "svg",
+               attributes = mapOf(
+                       "width" to svgDoc.width.xmlValue,
+                       "height" to svgDoc.height.xmlValue,
+                       "viewBox" to listOf(
+                               svgDoc.viewBoxX,
+                               svgDoc.viewBoxY,
+                               svgDoc.viewBoxW,
+                               svgDoc.viewBoxH,
+                       ).joinToString(separator = " ") { it.xmlValue }
+               )
+       ) { "path"(attributes = mapOf("d" to svgDoc.path.d, "fill-rule" to svgDoc.path.fillRule)) }
+
+object MechyrdiaSansFont {
+       private val logger: Logger = LoggerFactory.getLogger(MechyrdiaSansFont::class.java)
+       
+       suspend fun renderTextToSvg(text: String, bold: Boolean, italic: Boolean, align: TextAlignment): SvgDoc {
+               val (file, font) = getFont(bold, italic)
+               val shape = layoutText(text, file, font, align)
+               return createSvgDocument(shape, 80.0 / file.unitsPerEm, 12.0)
+       }
+       
+       private val fontsRoot = StoragePath("fonts")
+       private fun fontFile(name: String) = fontsRoot / "$name.ttf"
+       private suspend fun loadFont(fontFile: StoragePath): Pair<TTFFile, Font>? {
+               val bytes = FileStorage.instance.readFile(fontFile) ?: return null
+               
+               return withContext(Dispatchers.IO) {
+                       val file = TTFFile(true, true)
+                       file.readFont(FontFileReader(ByteArrayInputStream(bytes)))
+                       
+                       val font = Font
+                               .createFont(Font.TRUETYPE_FONT, ByteArrayInputStream(bytes))
+                               .deriveFont(file.unitsPerEm.toFloat())
+                       
+                       file to font
+               }
+       }
+       
+       private fun loadedFont(fontName: String): ReadOnlyProperty<Any?, suspend () -> Pair<TTFFile, Font>?> {
+               return storedData(fontFile(fontName), ::loadFont)
+       }
+       
+       private val mechyrdiaSans by loadedFont("mechyrdia-sans")
+       private val mechyrdiaSansB by loadedFont("mechyrdia-sans-bold")
+       private val mechyrdiaSansI by loadedFont("mechyrdia-sans-italic")
+       private val mechyrdiaSansBI by loadedFont("mechyrdia-sans-bold-italic")
+       
+       private val mechyrdiaSansFonts = listOf(mechyrdiaSans, mechyrdiaSansI, mechyrdiaSansB, mechyrdiaSansBI)
+       private suspend fun getFont(bold: Boolean, italic: Boolean): Pair<TTFFile, Font> {
+               return mechyrdiaSansFonts[(if (bold) 2 else 0) + (if (italic) 1 else 0)]()!!
+       }
+       
+       private fun TTFFile.getGlyph(cp: Int): Int {
+               return try {
+                       unicodeToGlyph(cp)
+               } catch (ex: IOException) {
+                       0
+               }
+       }
+       
+       private fun String.toCodePointSequence() = sequence {
+               val l = length
+               var i = 0
+               while (i < l) {
+                       val cp = Character.codePointAt(this@toCodePointSequence, i)
+                       i += if (Character.isSupplementaryCodePoint(cp)) 2 else 1
+                       yield(cp)
+               }
+       }
+       
+       private fun String.toCodePointArray(): IntArray {
+               val iter = toCodePointSequence().iterator()
+               
+               return IntArray(codePointCount(0, length)) { _ ->
+                       assert(iter.hasNext())
+                       iter.next()
+               }
+       }
+       
+       private fun TTFFile.getGlyphs(str: String): GlyphSequence {
+               val codes = str.toCodePointArray()
+               val glyphs = IntArray(codes.size) { i -> getGlyph(codes[i]) }
+               
+               return GlyphSequence(IntBuffer.wrap(codes), IntBuffer.wrap(glyphs), null)
+       }
+       
+       private fun TTFFile.getBasicWidths(glyphSequence: GlyphSequence): IntArray {
+               return IntArray(glyphSequence.glyphCount) { i ->
+                       if (i == 0)
+                               mtx[glyphSequence.getGlyph(i)].wx
+                       else {
+                               val prev = glyphSequence.getGlyph(i - 1)
+                               val curr = glyphSequence.getGlyph(i)
+                               (rawKerning[prev]?.get(curr) ?: 0) + mtx[curr].wx
+                       }
+               }
+       }
+       
+       private fun TTFFile.getGlyphPositions(glyphSequence: GlyphSequence, widths: IntArray): Array<IntArray> {
+               val adjustments = Array(glyphSequence.glyphCount) { IntArray(4) }
+               gpos.position(glyphSequence, "latn", "*", 0, widths, adjustments)
+               
+               // I don't know why this is necessary,
+               // but it gives me the results I want.
+               for (adjustment in adjustments) {
+                       adjustment[0] *= 2
+                       adjustment[1] *= 2
+                       adjustment[2] *= 2
+                       adjustment[3] *= 2
+               }
+               
+               return adjustments
+       }
+       
+       private fun getWidth(widths: IntArray, glyphPositions: Array<IntArray>): Int {
+               return widths.zip(glyphPositions) { width, pos -> width + pos[2] }.sum()
+       }
+       
+       private fun layoutText(text: String, file: TTFFile, font: Font, align: TextAlignment): Shape {
+               val img = BufferedImage(256, 160, BufferedImage.TYPE_INT_ARGB)
+               val g2d = img.createGraphics()
+               try {
+                       val charHolder = CharArray(2)
+                       val lineHeight = file.rawLowerCaseAscent - file.rawLowerCaseDescent
+                       
+                       val lines = text.split("\r\n", "\n", "\r")
+                       val lineGlyphs = lines.map { file.getGlyphs(it) }
+                       val lineBasics = lineGlyphs.map { file.getBasicWidths(it) }
+                       val lineAdjust = lineGlyphs.zip(lineBasics) { glyphs, widths -> file.getGlyphPositions(glyphs, widths) }
+                       val lineWidths = lineBasics.zip(lineAdjust) { width, adjust -> getWidth(width, adjust) }
+                       val blockWidth = lineWidths.max()
+                       var ly = 0
+                       
+                       yieldThread()
+                       
+                       val shape = GeneralPath()
+                       val tf = AffineTransform()
+                       for ((li, line) in lines.withIndex()) {
+                               if (line.isNotBlank()) {
+                                       val lineWidth = lineWidths[li]
+                                       val lx = align.processWidth(blockWidth - lineWidth)
+                                       
+                                       var cx = 0
+                                       var cy = 0
+                                       
+                                       val basicAdv = lineBasics[li]
+                                       val adjusted = lineAdjust[li]
+                                       val glyphSeq = lineGlyphs[li]
+                                       for ((ci, codePoint) in glyphSeq.getCharacterArray(false).withIndex()) {
+                                               val length = Character.toChars(codePoint, charHolder, 0)
+                                               val glyph = font.layoutGlyphVector(g2d.fontRenderContext, charHolder, 0, length, Font.LAYOUT_LEFT_TO_RIGHT)
+                                               val glyphShape = glyph.outline as GeneralPath
+                                               val glyphShift = adjusted[ci]
+                                               
+                                               tf.setToTranslation((lx + cx + glyphShift[0]).toDouble(), (ly + cy + glyphShift[1]).toDouble())
+                                               shape.append(glyphShape.getPathIterator(tf), false)
+                                               
+                                               cx += glyphShift[2] + basicAdv[ci]
+                                               cy += glyphShift[3]
+                                       }
+                               }
+                               
+                               ly += lineHeight
+                               
+                               yieldThread()
+                       }
+                       
+                       return shape
+               } catch (ex: Exception) {
+                       logger.error("Error converting text $text to font shape", ex)
+                       return GeneralPath()
+               } finally {
+                       g2d.dispose()
+               }
+       }
+       
+       private fun createSvgDocument(shape: Shape, scale: Double, padding: Double = 0.0): SvgDoc {
+               val viewBox = shape.bounds2D
+               val vBoxPad = padding / scale
+               val sizePad = padding * 2
+               
+               val path = shape.calculateSvgPath()
+               
+               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
+                       
+                       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()
+                       }
+               }
+               
+               val fillRule = when (val winding = iterator.windingRule) {
+                       PathIterator.WIND_EVEN_ODD -> "evenodd"
+                       PathIterator.WIND_NON_ZERO -> "nonzero"
+                       else -> error("Invalid winding rule $winding")
+               }
+               
+               return SvgPath(d, fillRule)
+       }
+}
+
+object TylanAlphabetFont {
+       private val allowedTranslitCharacters = setOf(
+               ' ', '\r', '\n', '\t',
+               'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'v', 'x', 'y',
+               '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
+               '~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '=', '+',
+               '[', '{', '}', ']', '\\', '|', '/', '<',
+               '.', ',', ':', ';', '\'', '"', '?', '>',
+       )
+       
+       private val replacements = listOf(
+               Regex("([0-9xy]+)(?![\\s0-9xy])") to "$1 ",
+               Regex("(?<![\\s0-9xy])([0-9xy]+)") to " $1",
+               
+               Regex("([a-pr-v'])m(?![\\w'])") to "$1A",
+               Regex("([a-pr-v'])n(?![\\w'])") to "$1E",
+               Regex("([a-ps-v'])r(?![\\w'])") to "$1H",
+               Regex("([ac-fh-jl-or-v'])s(?![\\w'])") to "$1I",
+               Regex("([a-pr-v'])t(?![\\w'])") to "$1Q",
+               
+               Regex("x") to "M",
+               Regex("y") to "N",
+               Regex("[qw-z]") to "",
+               
+               Regex("ch") to "C",
+               Regex("dh") to "D",
+               Regex("sh") to "S",
+               Regex("th") to "T",
+               
+               Regex("bs") to "B",
+               Regex("gs") to "G",
+               Regex("ks") to "K",
+               Regex("ps") to "P",
+               
+               Regex("b'([Is])") to "b$1",
+               Regex("g'([Is])") to "g$1",
+               Regex("k'([Is])") to "k$1",
+               Regex("p'([Is])") to "p$1",
+               
+               Regex("ff") to "F",
+               Regex("fv") to "V",
+               Regex("hj") to "J",
+               Regex("lh") to "L",
+               Regex("ll") to "L",
+               Regex("hl") to "L",
+               Regex("rh") to "R",
+               Regex("rr") to "R",
+               Regex("hr") to "R",
+               Regex("d'h") to "dh",
+               Regex("l'h") to "lh",
+               Regex("l'J") to "lJ",
+               Regex("r'h") to "rh",
+               Regex("r'J") to "rJ",
+               Regex("s'h") to "sh",
+               Regex("t'h") to "th",
+               Regex("h'l") to "hl",
+               Regex("h'r") to "hr",
+               Regex("B'h") to "Bh",
+               Regex("G'h") to "Gh",
+               Regex("K'h") to "Kh",
+               Regex("P'h") to "Ph",
+               Regex("c(?!h)") to "",
+               
+               Regex("ae") to "W",
+               Regex("au") to "X",
+               Regex("ea") to "Y",
+               Regex("ei") to "Z",
+               Regex("eo") to "w",
+               Regex("eu") to "x",
+               Regex("oa") to "y",
+               Regex("ou") to "z",
+               Regex("oe") to "O",
+               Regex("ui") to "U",
+               
+               Regex("i([aeiouOUw-zW-Z])") to "ij$1",
+               Regex("^([aeiouOUw-zW-Z])") to "'$1",
+               Regex("([^'BCDFGJKLPR-TVbdf-hj-npr-tv])([aeiouOUw-zW-Z])") to "$1'$2",
+               Regex("([aeiouOUw-zW-Z])([aeiouOUw-zW-Z])") to "$1'$2",
+       )
+       
+       fun tylanToFontAlphabet(tylan: String) = replacements.fold(tylan.lowercase().filter { it in allowedTranslitCharacters }) { partial, (regex, replacement) ->
+               partial.replace(regex, replacement)
+       }
+}
+
+object PokhwalishAlphabetFont {
+       private val allowedTranslitCharacters = setOf(
+               ' ', '\r', '\n', '\t',
+               'a', 'b', 'c', 'd', 'e', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'y', 'z',
+               '.', ',', '\'', '?', '!',
+       )
+       
+       private val replacements = listOf(
+               // Vowels
+               Regex("aa") to "A",
+               Regex("ae") to "A",
+               Regex("ee") to "E",
+               Regex("ei") to "E",
+               Regex("ey") to "E",
+               Regex("ie") to "I",
+               Regex("ii") to "I",
+               Regex("iy") to "I",
+               Regex("ao") to "O",
+               Regex("au") to "O",
+               Regex("oo") to "O",
+               Regex("ou") to "U",
+               Regex("ue") to "U",
+               Regex("ui") to "U",
+               Regex("uu") to "U",
+               // Consonants
+               Regex("tz") to "C",
+               Regex("hh") to "K",
+               Regex("kh") to "K",
+               Regex("gh") to "G",
+               Regex("ng(?![aeiouAEIOU])") to "N",
+               Regex("ng([aeiouAEIOU])") to "Ng$1",
+               Regex("n'g") to "ng",
+               Regex("qh") to "Q",
+               Regex("th") to "T",
+               
+               Regex("ck") to "q",
+               Regex("c") to "",
+               Regex("k") to "q",
+       )
+       
+       fun pokhwalToFontAlphabet(pokhwal: String) = replacements.fold(pokhwal.lowercase().filter { it in allowedTranslitCharacters }) { partial, (regex, replacement) ->
+               partial.replace(regex, replacement)
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt
deleted file mode 100644 (file)
index ac5411e..0000000
+++ /dev/null
@@ -1,408 +0,0 @@
-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.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import info.mechyrdia.route.KeyedEnumSerializer
-import info.mechyrdia.yieldThread
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.Serializable
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import java.awt.Font
-import java.awt.Shape
-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.IOException
-import java.nio.IntBuffer
-import kotlin.properties.ReadOnlyProperty
-
-@Serializable(with = TextAlignmentSerializer::class)
-enum class TextAlignment {
-       LEFT {
-               override fun processWidth(widthDiff: Int): Int {
-                       return 0
-               }
-       },
-       CENTER {
-               override fun processWidth(widthDiff: Int): Int {
-                       return widthDiff / 2
-               }
-       },
-       RIGHT {
-               override fun processWidth(widthDiff: Int): Int {
-                       return widthDiff
-               }
-       };
-       
-       abstract fun processWidth(widthDiff: Int): Int
-}
-
-object TextAlignmentSerializer : KeyedEnumSerializer<TextAlignment>(TextAlignment.entries)
-
-object MechyrdiaSansFont {
-       private val logger: Logger = LoggerFactory.getLogger(MechyrdiaSansFont::class.java)
-       
-       suspend fun renderTextToSvg(text: String, bold: Boolean, italic: Boolean, align: TextAlignment): String {
-               val (file, font) = getFont(bold, italic)
-               return layoutText(text, file, font, align).toSvgDocument(80.0 / file.unitsPerEm, 12.0)
-       }
-       
-       private val fontsRoot = StoragePath("fonts")
-       private fun fontFile(name: String) = fontsRoot / "$name.ttf"
-       private suspend fun loadFont(fontFile: StoragePath): Pair<TTFFile, Font>? {
-               val bytes = FileStorage.instance.readFile(fontFile) ?: return null
-               
-               return withContext(Dispatchers.IO) {
-                       val file = TTFFile(true, true)
-                       file.readFont(FontFileReader(ByteArrayInputStream(bytes)))
-                       
-                       val font = Font
-                               .createFont(Font.TRUETYPE_FONT, ByteArrayInputStream(bytes))
-                               .deriveFont(file.unitsPerEm.toFloat())
-                       
-                       file to font
-               }
-       }
-       
-       private fun loadedFont(fontName: String): ReadOnlyProperty<Any?, suspend () -> Pair<TTFFile, Font>?> {
-               return storedData(fontFile(fontName), ::loadFont)
-       }
-       
-       private val mechyrdiaSans by loadedFont("mechyrdia-sans")
-       private val mechyrdiaSansB by loadedFont("mechyrdia-sans-bold")
-       private val mechyrdiaSansI by loadedFont("mechyrdia-sans-italic")
-       private val mechyrdiaSansBI by loadedFont("mechyrdia-sans-bold-italic")
-       
-       private val mechyrdiaSansFonts = listOf(mechyrdiaSans, mechyrdiaSansI, mechyrdiaSansB, mechyrdiaSansBI)
-       private suspend fun getFont(bold: Boolean, italic: Boolean): Pair<TTFFile, Font> {
-               return mechyrdiaSansFonts[(if (bold) 2 else 0) + (if (italic) 1 else 0)]()!!
-       }
-       
-       private fun TTFFile.getGlyph(cp: Int): Int {
-               return try {
-                       unicodeToGlyph(cp)
-               } catch (ex: IOException) {
-                       0
-               }
-       }
-       
-       private fun String.toCodePointSequence() = sequence {
-               val l = length
-               var i = 0
-               while (i < l) {
-                       val cp = Character.codePointAt(this@toCodePointSequence, i)
-                       i += if (Character.isSupplementaryCodePoint(cp)) 2 else 1
-                       yield(cp)
-               }
-       }
-       
-       private fun String.toCodePointArray(): IntArray {
-               val iter = toCodePointSequence().iterator()
-               
-               return IntArray(codePointCount(0, length)) { _ ->
-                       assert(iter.hasNext())
-                       iter.next()
-               }
-       }
-       
-       private fun TTFFile.getGlyphs(str: String): GlyphSequence {
-               val codes = str.toCodePointArray()
-               val glyphs = IntArray(codes.size) { i -> getGlyph(codes[i]) }
-               
-               return GlyphSequence(IntBuffer.wrap(codes), IntBuffer.wrap(glyphs), null)
-       }
-       
-       private fun TTFFile.getBasicWidths(glyphSequence: GlyphSequence): IntArray {
-               return IntArray(glyphSequence.glyphCount) { i ->
-                       if (i == 0)
-                               mtx[glyphSequence.getGlyph(i)].wx
-                       else {
-                               val prev = glyphSequence.getGlyph(i - 1)
-                               val curr = glyphSequence.getGlyph(i)
-                               (rawKerning[prev]?.get(curr) ?: 0) + mtx[curr].wx
-                       }
-               }
-       }
-       
-       private fun TTFFile.getGlyphPositions(glyphSequence: GlyphSequence, widths: IntArray): Array<IntArray> {
-               val adjustments = Array(glyphSequence.glyphCount) { IntArray(4) }
-               gpos.position(glyphSequence, "latn", "*", 0, widths, adjustments)
-               
-               // I don't know why this is necessary,
-               // but it gives me the results I want.
-               for (adjustment in adjustments) {
-                       adjustment[0] *= 2
-                       adjustment[1] *= 2
-                       adjustment[2] *= 2
-                       adjustment[3] *= 2
-               }
-               
-               return adjustments
-       }
-       
-       private fun getWidth(widths: IntArray, glyphPositions: Array<IntArray>): Int {
-               return widths.zip(glyphPositions) { width, pos -> width + pos[2] }.sum()
-       }
-       
-       private fun layoutText(text: String, file: TTFFile, font: Font, align: TextAlignment): Shape {
-               val img = BufferedImage(256, 160, BufferedImage.TYPE_INT_ARGB)
-               val g2d = img.createGraphics()
-               try {
-                       val charHolder = CharArray(2)
-                       val lineHeight = file.rawLowerCaseAscent - file.rawLowerCaseDescent
-                       
-                       val lines = text.split("\r\n", "\n", "\r")
-                       val lineGlyphs = lines.map { file.getGlyphs(it) }
-                       val lineBasics = lineGlyphs.map { file.getBasicWidths(it) }
-                       val lineAdjust = lineGlyphs.zip(lineBasics) { glyphs, widths -> file.getGlyphPositions(glyphs, widths) }
-                       val lineWidths = lineBasics.zip(lineAdjust) { width, adjust -> getWidth(width, adjust) }
-                       val blockWidth = lineWidths.max()
-                       var ly = 0
-                       
-                       yieldThread()
-                       
-                       val shape = GeneralPath()
-                       val tf = AffineTransform()
-                       for ((li, line) in lines.withIndex()) {
-                               if (line.isNotBlank()) {
-                                       val lineWidth = lineWidths[li]
-                                       val lx = align.processWidth(blockWidth - lineWidth)
-                                       
-                                       var cx = 0
-                                       var cy = 0
-                                       
-                                       val basicAdv = lineBasics[li]
-                                       val adjusted = lineAdjust[li]
-                                       val glyphSeq = lineGlyphs[li]
-                                       for ((ci, codePoint) in glyphSeq.getCharacterArray(false).withIndex()) {
-                                               val length = Character.toChars(codePoint, charHolder, 0)
-                                               val glyph = font.layoutGlyphVector(g2d.fontRenderContext, charHolder, 0, length, Font.LAYOUT_LEFT_TO_RIGHT)
-                                               val glyphShape = glyph.outline as GeneralPath
-                                               val glyphShift = adjusted[ci]
-                                               
-                                               tf.setToTranslation((lx + cx + glyphShift[0]).toDouble(), (ly + cy + glyphShift[1]).toDouble())
-                                               shape.append(glyphShape.getPathIterator(tf), false)
-                                               
-                                               cx += glyphShift[2] + basicAdv[ci]
-                                               cy += glyphShift[3]
-                                       }
-                               }
-                               
-                               ly += lineHeight
-                               
-                               yieldThread()
-                       }
-                       
-                       return shape
-               } catch (ex: Exception) {
-                       logger.error("Error converting text $text to font shape", ex)
-                       return GeneralPath()
-               } finally {
-                       g2d.dispose()
-               }
-       }
-       
-       private fun Shape.toSvgDocument(scale: Double, padding: Double = 0.0): String {
-               return buildString {
-                       appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>")
-                       
-                       val viewBox = bounds2D
-                       val vBoxPad = padding / scale
-                       val sizePad = padding * 2
-                       
-                       appendLine("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${(viewBox.width * scale) + sizePad}\" height=\"${(viewBox.height * scale) + sizePad}\" viewBox=\"${viewBox.minX - vBoxPad} ${viewBox.minY - vBoxPad} ${viewBox.width + (vBoxPad * 2)} ${viewBox.height + (vBoxPad * 2)}\">")
-                       appendLine(toSvgPath())
-                       appendLine("</svg>")
-               }
-       }
-       
-       private fun Shape.toSvgPath(): String {
-               return buildString {
-                       append("<path d=\"")
-                       
-                       val iterator = getPathIterator(null)
-                       val coords = DoubleArray(6)
-                       var isFirst = true
-                       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()
-                       }
-                       
-                       append("\" fill-rule=\"")
-                       when (val winding = iterator.windingRule) {
-                               PathIterator.WIND_EVEN_ODD -> append("evenodd")
-                               PathIterator.WIND_NON_ZERO -> append("nonzero")
-                               else -> error("Invalid winding rule $winding")
-                       }
-                       
-                       append("\" />")
-               }
-       }
-}
-
-object TylanAlphabetFont {
-       private val allowedTranslitCharacters = setOf(
-               ' ', '\r', '\n', '\t',
-               'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'v', 'x', 'y',
-               '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
-               '~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '=', '+',
-               '[', '{', '}', ']', '\\', '|', '/', '<',
-               '.', ',', ':', ';', '\'', '"', '?', '>',
-       )
-       
-       private val replacements = listOf(
-               Regex("([0-9xy]+)(?![\\s0-9xy])") to "$1 ",
-               Regex("(?<![\\s0-9xy])([0-9xy]+)") to " $1",
-               
-               Regex("([a-pr-v'])m(?![\\w'])") to "$1A",
-               Regex("([a-pr-v'])n(?![\\w'])") to "$1E",
-               Regex("([a-ps-v'])r(?![\\w'])") to "$1H",
-               Regex("([ac-fh-jl-or-v'])s(?![\\w'])") to "$1I",
-               Regex("([a-pr-v'])t(?![\\w'])") to "$1Q",
-               
-               Regex("x") to "M",
-               Regex("y") to "N",
-               Regex("[qw-z]") to "",
-               
-               Regex("ch") to "C",
-               Regex("dh") to "D",
-               Regex("sh") to "S",
-               Regex("th") to "T",
-               
-               Regex("bs") to "B",
-               Regex("gs") to "G",
-               Regex("ks") to "K",
-               Regex("ps") to "P",
-               
-               Regex("b'([Is])") to "b$1",
-               Regex("g'([Is])") to "g$1",
-               Regex("k'([Is])") to "k$1",
-               Regex("p'([Is])") to "p$1",
-               
-               Regex("ff") to "F",
-               Regex("fv") to "V",
-               Regex("hj") to "J",
-               Regex("lh") to "L",
-               Regex("ll") to "L",
-               Regex("hl") to "L",
-               Regex("rh") to "R",
-               Regex("rr") to "R",
-               Regex("hr") to "R",
-               Regex("d'h") to "dh",
-               Regex("l'h") to "lh",
-               Regex("l'J") to "lJ",
-               Regex("r'h") to "rh",
-               Regex("r'J") to "rJ",
-               Regex("s'h") to "sh",
-               Regex("t'h") to "th",
-               Regex("h'l") to "hl",
-               Regex("h'r") to "hr",
-               Regex("B'h") to "Bh",
-               Regex("G'h") to "Gh",
-               Regex("K'h") to "Kh",
-               Regex("P'h") to "Ph",
-               Regex("c(?!h)") to "",
-               
-               Regex("ae") to "W",
-               Regex("au") to "X",
-               Regex("ea") to "Y",
-               Regex("ei") to "Z",
-               Regex("eo") to "w",
-               Regex("eu") to "x",
-               Regex("oa") to "y",
-               Regex("ou") to "z",
-               Regex("oe") to "O",
-               Regex("ui") to "U",
-               
-               Regex("i([aeiouOUw-zW-Z])") to "ij$1",
-               Regex("^([aeiouOUw-zW-Z])") to "'$1",
-               Regex("([^'BCDFGJKLPR-TVbdf-hj-npr-tv])([aeiouOUw-zW-Z])") to "$1'$2",
-               Regex("([aeiouOUw-zW-Z])([aeiouOUw-zW-Z])") to "$1'$2",
-       )
-       
-       fun tylanToFontAlphabet(tylan: String) = replacements.fold(tylan.lowercase().filter { it in allowedTranslitCharacters }) { partial, (regex, replacement) ->
-               partial.replace(regex, replacement)
-       }
-}
-
-object PokhwalishAlphabetFont {
-       private val allowedTranslitCharacters = setOf(
-               ' ', '\r', '\n', '\t',
-               'a', 'b', 'c', 'd', 'e', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'y', 'z',
-               '.', ',', '\'', '?', '!',
-       )
-       
-       private val replacements = listOf(
-               // Vowels
-               Regex("aa") to "A",
-               Regex("ae") to "A",
-               Regex("ee") to "E",
-               Regex("ei") to "E",
-               Regex("ey") to "E",
-               Regex("ie") to "I",
-               Regex("ii") to "I",
-               Regex("iy") to "I",
-               Regex("ao") to "O",
-               Regex("au") to "O",
-               Regex("oo") to "O",
-               Regex("ou") to "U",
-               Regex("ue") to "U",
-               Regex("ui") to "U",
-               Regex("uu") to "U",
-               // Consonants
-               Regex("tz") to "C",
-               Regex("hh") to "K",
-               Regex("kh") to "K",
-               Regex("gh") to "G",
-               Regex("ng(?![aeiouAEIOU])") to "N",
-               Regex("ng([aeiouAEIOU])") to "Ng$1",
-               Regex("n'g") to "ng",
-               Regex("qh") to "Q",
-               Regex("th") to "T",
-               
-               Regex("ck") to "q",
-               Regex("c") to "",
-               Regex("k") to "q",
-       )
-       
-       fun pokhwalToFontAlphabet(pokhwal: String) = replacements.fold(pokhwal.lowercase().filter { it in allowedTranslitCharacters }) { partial, (regex, replacement) ->
-               partial.replace(regex, replacement)
-       }
-}