diff --git a/.idea/modules.xml b/.idea/modules.xml
index 46a4f1d..533331d 100755
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -4,6 +4,7 @@
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 43e0a43..b3cf9bf 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -9,84 +9,10 @@
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -242,12 +168,6 @@
-
-
-
-
-
-
@@ -298,7 +218,23 @@
1726151824465
-
+
+
+ 1771460240293
+
+
+
+ 1771460240293
+
+
+
+ 1771551906182
+
+
+
+ 1771551906182
+
+
@@ -324,7 +260,9 @@
-
+
+
+
diff --git a/OTFbuild/OTFbuild.iml b/OTFbuild/OTFbuild.iml
new file mode 100644
index 0000000..1ad6bc2
--- /dev/null
+++ b/OTFbuild/OTFbuild.iml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OTFbuild/build_otf.sh b/OTFbuild/build_otf.sh
new file mode 100755
index 0000000..de63ff3
--- /dev/null
+++ b/OTFbuild/build_otf.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+ASSETS_DIR="$PROJECT_DIR/src/assets"
+OUTPUT_DIR="$SCRIPT_DIR"
+BITSNPICAS_JAR="$SCRIPT_DIR/bitsnpicas_runtime/BitsNPicas.jar"
+
+# Output paths
+KBITX_OUTPUT="$OUTPUT_DIR/TerrarumSansBitmap.kbitx"
+TTF_OUTPUT="$OUTPUT_DIR/TerrarumSansBitmap.ttf"
+
+echo "=== Terrarum Sans Bitmap OTF Build Pipeline ==="
+echo "Project: $PROJECT_DIR"
+echo "Assets: $ASSETS_DIR"
+echo ""
+
+# Step 1: Compile the builder
+echo "--- Step 1: Compiling OTFbuild module ---"
+COMPILE_CLASSPATH="$BITSNPICAS_JAR"
+SRC_DIR="$SCRIPT_DIR/src"
+OUT_DIR="$SCRIPT_DIR/out"
+
+mkdir -p "$OUT_DIR"
+
+# Find all Kotlin source files
+SRC_FILES=$(find "$SRC_DIR" -name "*.kt" | tr '\n' ' ')
+
+# Try to find Kotlin compiler
+if command -v kotlinc &> /dev/null; then
+ KOTLINC="kotlinc"
+ KOTLIN_STDLIB=""
+else
+ # Try IntelliJ's bundled Kotlin
+ IDEA_CACHE="$HOME/.cache/JetBrains"
+ KOTLIN_DIST=$(find "$IDEA_CACHE" -path "*/kotlin-dist-for-ide/*/lib/kotlin-compiler.jar" 2>/dev/null | sort -V | tail -1)
+ if [ -n "$KOTLIN_DIST" ]; then
+ KOTLIN_LIB="$(dirname "$KOTLIN_DIST")"
+ KOTLINC_CP="$KOTLIN_LIB/kotlin-compiler.jar:$KOTLIN_LIB/kotlin-stdlib.jar:$KOTLIN_LIB/trove4j.jar:$KOTLIN_LIB/kotlin-reflect.jar:$KOTLIN_LIB/kotlin-script-runtime.jar:$KOTLIN_LIB/kotlin-daemon.jar:$KOTLIN_LIB/annotations-13.0.jar"
+ KOTLIN_STDLIB="$KOTLIN_LIB/kotlin-stdlib.jar:$KOTLIN_LIB/kotlin-stdlib-jdk7.jar:$KOTLIN_LIB/kotlin-stdlib-jdk8.jar"
+ echo "Using IntelliJ's Kotlin from: $KOTLIN_LIB"
+ else
+ echo "ERROR: kotlinc not found. Please install Kotlin compiler or build via IntelliJ IDEA."
+ echo ""
+ echo "Alternative: Build the OTFbuild module in IntelliJ IDEA, then run:"
+ echo " java -cp \"$OUT_DIR:$COMPILE_CLASSPATH\" net.torvald.otfbuild.MainKt \"$ASSETS_DIR\" \"$KBITX_OUTPUT\""
+ exit 1
+ fi
+fi
+
+if [ -n "$KOTLIN_STDLIB" ]; then
+ # Use IntelliJ's bundled Kotlin via java
+ java -cp "$KOTLINC_CP" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler \
+ -cp "$COMPILE_CLASSPATH:$KOTLIN_STDLIB" -d "$OUT_DIR" $SRC_FILES
+else
+ kotlinc -cp "$COMPILE_CLASSPATH" -d "$OUT_DIR" $SRC_FILES
+ KOTLIN_STDLIB=""
+fi
+
+# Step 2: Run the builder to generate KBITX
+echo ""
+echo "--- Step 2: Generating KBITX ---"
+RUNTIME_CP="$OUT_DIR:$COMPILE_CLASSPATH"
+if [ -n "$KOTLIN_STDLIB" ]; then
+ RUNTIME_CP="$RUNTIME_CP:$KOTLIN_STDLIB"
+fi
+java -cp "$RUNTIME_CP" net.torvald.otfbuild.MainKt "$ASSETS_DIR" "$KBITX_OUTPUT"
+
+# Step 3: Convert KBITX to TTF via BitsNPicas
+echo ""
+echo "--- Step 3: Converting KBITX to TTF ---"
+java -jar "$BITSNPICAS_JAR" convertbitmap \
+ -f ttf -o "$TTF_OUTPUT" \
+ "$KBITX_OUTPUT"
+
+echo ""
+echo "=== Build complete ==="
+echo " KBITX: $KBITX_OUTPUT"
+echo " TTF: $TTF_OUTPUT"
diff --git a/OTFbuild/src/net/torvald/otfbuild/DevanagariTamilProcessor.kt b/OTFbuild/src/net/torvald/otfbuild/DevanagariTamilProcessor.kt
new file mode 100644
index 0000000..58db972
--- /dev/null
+++ b/OTFbuild/src/net/torvald/otfbuild/DevanagariTamilProcessor.kt
@@ -0,0 +1,133 @@
+package net.torvald.otfbuild
+
+/**
+ * Ensures all Devanagari, Tamil, Sundanese, and Alphabetic Presentation Forms
+ * PUA glyphs are included in the font. Since BitsNPicas doesn't support OpenType
+ * GSUB/GPOS features, complex text shaping must be done by the application.
+ *
+ * All the relevant PUA codepoints are already in the sprite sheets and extracted
+ * by GlyphSheetParser. This processor:
+ * 1. Verifies that key PUA ranges have been loaded
+ * 2. Ensures Unicode pre-composed forms (U+0958–U+095F) map correctly
+ * 3. Documents the mapping for reference
+ *
+ * The runtime normalise() function handles the actual Unicode → PUA mapping,
+ * but since we can't put GSUB tables into the KBITX/TTF, applications must
+ * use the PUA codepoints directly, or perform their own normalisation.
+ */
+class DevanagariTamilProcessor {
+
+ /**
+ * Verify that key PUA glyphs exist in the extracted set.
+ * Returns a set of codepoints that should be included but are missing.
+ */
+ fun verify(glyphs: Map): Set {
+ val missing = mutableSetOf()
+
+ // Devanagari special syllables
+ val devanagariSpecials = listOf(
+ 0xF0100, // Ru
+ 0xF0101, // Ruu
+ 0xF0102, // RRu
+ 0xF0103, // RRuu
+ 0xF0104, // Hu
+ 0xF0105, // Huu
+ 0xF0106, // RYA
+ 0xF0107, // Half-RYA
+ 0xF0108, // Open YA
+ 0xF0109, // Open Half-YA
+ 0xF010B, // Eyelash RA
+ 0xF010C, // RA superscript
+ 0xF010D, // RA superscript (complex)
+ 0xF010E, // DDRA (Marwari)
+ 0xF010F, // Alt Half SHA
+ )
+
+ // Devanagari presentation consonants (full forms)
+ val devaPresentation = (0xF0140..0xF022F).toList()
+ // Devanagari presentation consonants (half forms)
+ val devaHalf = (0xF0230..0xF031F).toList()
+ // Devanagari presentation consonants (with RA)
+ val devaRa = (0xF0320..0xF040F).toList()
+ // Devanagari presentation consonants (with RA, half forms)
+ val devaRaHalf = (0xF0410..0xF04FF).toList()
+
+ // Devanagari II variant forms
+ val devaII = (0xF0110..0xF012F).toList()
+
+ // Devanagari named ligatures
+ val devaLigatures = listOf(
+ 0xF01A1, // K.SS
+ 0xF01A2, // J.NY
+ 0xF01A3, // T.T
+ 0xF01A4, // N.T
+ 0xF01A5, // N.N
+ 0xF01A6, // S.V
+ 0xF01A7, // SS.P
+ 0xF01A8, // SH.C
+ 0xF01A9, // SH.N
+ 0xF01AA, // SH.V
+ 0xF01AB, // J.Y
+ 0xF01AC, // J.J.Y
+ 0xF01BC, // K.T
+ // D-series ligatures
+ 0xF01B0, 0xF01B1, 0xF01B2, 0xF01B3, 0xF01B4,
+ 0xF01B5, 0xF01B6, 0xF01B7, 0xF01B8, 0xF01B9,
+ // Marwari
+ 0xF01BA, 0xF01BB,
+ // Extended ligatures
+ 0xF01BD, 0xF01BE, 0xF01BF,
+ 0xF01C0, 0xF01C1, 0xF01C2, 0xF01C3, 0xF01C4, 0xF01C5,
+ 0xF01C6, 0xF01C7, 0xF01C8, 0xF01C9, 0xF01CA, 0xF01CB,
+ 0xF01CD, 0xF01CE, 0xF01CF,
+ 0xF01D0, 0xF01D1, 0xF01D2, 0xF01D3, 0xF01D4, 0xF01D5,
+ 0xF01D6, 0xF01D7, 0xF01D8, 0xF01D9, 0xF01DA,
+ 0xF01DB, 0xF01DC, 0xF01DD, 0xF01DE, 0xF01DF,
+ 0xF01E0, 0xF01E1, 0xF01E2, 0xF01E3,
+ )
+
+ // Tamil ligatures
+ val tamilLigatures = listOf(
+ 0xF00C0, 0xF00C1, // TTA+I, TTA+II
+ 0xF00ED, // KSSA
+ 0xF00EE, // SHRII
+ 0xF00F0, 0xF00F1, 0xF00F2, 0xF00F3, 0xF00F4, 0xF00F5, // consonant+I
+ ) + (0xF00C2..0xF00D3).toList() + // consonant+U
+ (0xF00D4..0xF00E5).toList() // consonant+UU
+
+ // Sundanese internal forms
+ val sundanese = listOf(
+ 0xF0500, // ING
+ 0xF0501, // ENG
+ 0xF0502, // EUNG
+ 0xF0503, // IR
+ 0xF0504, // ER
+ 0xF0505, // EUR
+ 0xF0506, // LU
+ )
+
+ // Alphabetic Presentation Forms (already in sheet 38)
+ // FB00–FB06 (Latin ligatures), FB13–FB17 (Armenian ligatures)
+
+ // Check all expected ranges
+ val allExpected = devanagariSpecials + devaPresentation + devaHalf + devaRa + devaRaHalf +
+ devaII + devaLigatures + tamilLigatures + sundanese
+
+ for (cp in allExpected) {
+ if (!glyphs.containsKey(cp)) {
+ missing.add(cp)
+ }
+ }
+
+ if (missing.isNotEmpty()) {
+ println(" [DevanagariTamilProcessor] ${missing.size} expected PUA glyphs missing")
+ // Only warn for the first few
+ missing.take(10).forEach { println(" Missing: U+${it.toString(16).uppercase().padStart(5, '0')}") }
+ if (missing.size > 10) println(" ... and ${missing.size - 10} more")
+ } else {
+ println(" [DevanagariTamilProcessor] All expected PUA glyphs present")
+ }
+
+ return missing
+ }
+}
diff --git a/OTFbuild/src/net/torvald/otfbuild/GlyphSheetParser.kt b/OTFbuild/src/net/torvald/otfbuild/GlyphSheetParser.kt
new file mode 100644
index 0000000..bcd9998
--- /dev/null
+++ b/OTFbuild/src/net/torvald/otfbuild/GlyphSheetParser.kt
@@ -0,0 +1,321 @@
+package net.torvald.otfbuild
+
+import com.kreative.bitsnpicas.BitmapFontGlyph
+import java.io.File
+
+/**
+ * Glyph properties extracted from tag column.
+ * Mirrors GlyphProps from the runtime but is standalone.
+ */
+data class ExtractedGlyphProps(
+ val width: Int,
+ val isLowHeight: Boolean = false,
+ val nudgeX: Int = 0,
+ val nudgeY: Int = 0,
+ val alignWhere: Int = 0,
+ val writeOnTop: Int = -1,
+ val stackWhere: Int = 0,
+ val hasKernData: Boolean = false,
+ val isKernYtype: Boolean = false,
+ val kerningMask: Int = 255,
+ val directiveOpcode: Int = 0,
+ val directiveArg1: Int = 0,
+ val directiveArg2: Int = 0,
+ val extInfo: IntArray = IntArray(15),
+) {
+ companion object {
+ const val ALIGN_LEFT = 0
+ const val ALIGN_RIGHT = 1
+ const val ALIGN_CENTRE = 2
+ const val ALIGN_BEFORE = 3
+
+ const val STACK_UP = 0
+ const val STACK_DOWN = 1
+ const val STACK_BEFORE_N_AFTER = 2
+ const val STACK_UP_N_DOWN = 3
+ const val STACK_DONT = 4
+ }
+
+ fun requiredExtInfoCount(): Int =
+ if (stackWhere == STACK_BEFORE_N_AFTER) 2
+ else if (directiveOpcode in 0b10000_000..0b10000_111) 7
+ else 0
+
+ fun isPragma(pragma: String) = when (pragma) {
+ "replacewith" -> directiveOpcode in 0b10000_000..0b10000_111
+ else -> false
+ }
+
+ val isIllegal: Boolean get() = directiveOpcode == 255
+}
+
+data class ExtractedGlyph(
+ val codepoint: Int,
+ val props: ExtractedGlyphProps,
+ val bitmap: Array, // [row][col], 0 or -1(0xFF)
+)
+
+/**
+ * Extracts glyph bitmaps and properties from TGA sprite sheets.
+ * Ported from TerrarumSansBitmap.buildWidthTable() and related methods.
+ */
+class GlyphSheetParser(private val assetsDir: String) {
+
+ private fun Boolean.toInt() = if (this) 1 else 0
+ /** @return 32-bit number: if alpha channel is zero, return 0; else return the original value */
+ private fun Int.tagify() = if (this and 0xFF == 0) 0 else this
+
+ /**
+ * Parse all sheets and return a map of codepoint -> (props, bitmap).
+ */
+ fun parseAll(): Map {
+ val result = HashMap(65536)
+
+ SheetConfig.fileList.forEachIndexed { sheetIndex, filename ->
+ val file = File(assetsDir, filename)
+ if (!file.exists()) {
+ println(" [SKIP] $filename not found")
+ return@forEachIndexed
+ }
+
+ val isVariable = SheetConfig.isVariable(filename)
+ val isXYSwapped = SheetConfig.isXYSwapped(filename)
+ val isExtraWide = SheetConfig.isExtraWide(filename)
+ val cellW = SheetConfig.getCellWidth(sheetIndex)
+ val cellH = SheetConfig.getCellHeight(sheetIndex)
+ val cols = SheetConfig.getColumns(sheetIndex)
+
+ val image = TgaReader.read(file)
+
+ val statusParts = mutableListOf()
+ if (isVariable) statusParts.add("VARIABLE")
+ if (isXYSwapped) statusParts.add("XYSWAP")
+ if (isExtraWide) statusParts.add("EXTRAWIDE")
+ if (statusParts.isEmpty()) statusParts.add("STATIC")
+ println(" Loading [${statusParts.joinToString()}] $filename")
+
+ if (isVariable) {
+ parseVariableSheet(image, sheetIndex, cellW, cellH, cols, isXYSwapped, result)
+ } else {
+ parseFixedSheet(image, sheetIndex, cellW, cellH, cols, result)
+ }
+ }
+
+ // Add fixed-width overrides
+ addFixedWidthOverrides(result)
+
+ return result
+ }
+
+ /**
+ * Parse a variable-width sheet: extract tag column for properties, bitmap for glyph.
+ */
+ private fun parseVariableSheet(
+ image: TgaImage,
+ sheetIndex: Int,
+ cellW: Int,
+ cellH: Int,
+ cols: Int,
+ isXYSwapped: Boolean,
+ result: HashMap
+ ) {
+ val codeRangeList = SheetConfig.codeRange[sheetIndex]
+ val binaryCodeOffset = cellW - 1 // tag column is last pixel column of cell
+
+ codeRangeList.forEachIndexed { index, code ->
+ val cellX: Int
+ val cellY: Int
+
+ if (isXYSwapped) {
+ cellX = (index / cols) * cellW // row becomes X
+ cellY = (index % cols) * cellH // col becomes Y
+ } else {
+ cellX = (index % cols) * cellW
+ cellY = (index / cols) * cellH
+ }
+
+ val codeStartX = cellX + binaryCodeOffset
+ val codeStartY = cellY
+
+ // Parse tag column
+ val width = (0..4).fold(0) { acc, y ->
+ acc or ((image.getPixel(codeStartX, codeStartY + y).and(0xFF) != 0).toInt() shl y)
+ }
+ val isLowHeight = image.getPixel(codeStartX, codeStartY + 5).and(0xFF) != 0
+
+ // Kerning data
+ val kerningBit1 = image.getPixel(codeStartX, codeStartY + 6).tagify()
+ val kerningBit2 = image.getPixel(codeStartX, codeStartY + 7).tagify()
+ val kerningBit3 = image.getPixel(codeStartX, codeStartY + 8).tagify()
+ var isKernYtype = (kerningBit1 and 0x80000000.toInt()) != 0
+ var kerningMask = kerningBit1.ushr(8).and(0xFFFFFF)
+ val hasKernData = kerningBit1 and 0xFF != 0
+ if (!hasKernData) {
+ isKernYtype = false
+ kerningMask = 255
+ }
+
+ // Compiler directives
+ val compilerDirectives = image.getPixel(codeStartX, codeStartY + 9).tagify()
+ val directiveOpcode = compilerDirectives.ushr(24).and(255)
+ val directiveArg1 = compilerDirectives.ushr(16).and(255)
+ val directiveArg2 = compilerDirectives.ushr(8).and(255)
+
+ // Nudge
+ val nudgingBits = image.getPixel(codeStartX, codeStartY + 10).tagify()
+ val nudgeX = nudgingBits.ushr(24).toByte().toInt()
+ val nudgeY = nudgingBits.ushr(16).toByte().toInt()
+
+ // Diacritics anchors (we don't store them in ExtractedGlyphProps for now but could)
+ // For alignment and width, they are useful during composition but not in final output
+
+ // Alignment
+ val alignWhere = (0..1).fold(0) { acc, y ->
+ acc or ((image.getPixel(codeStartX, codeStartY + y + 15).and(0xFF) != 0).toInt() shl y)
+ }
+
+ // Write on top
+ var writeOnTop = image.getPixel(codeStartX, codeStartY + 17) // NO .tagify()
+ if (writeOnTop and 0xFF == 0) writeOnTop = -1
+ else {
+ writeOnTop = if (writeOnTop.ushr(8) == 0xFFFFFF) 0 else writeOnTop.ushr(28) and 15
+ }
+
+ // Stack where
+ val stackWhere0 = image.getPixel(codeStartX, codeStartY + 18).tagify()
+ val stackWhere1 = image.getPixel(codeStartX, codeStartY + 19).tagify()
+ val stackWhere = if (stackWhere0 == 0x00FF00FF && stackWhere1 == 0x00FF00FF)
+ ExtractedGlyphProps.STACK_DONT
+ else (0..1).fold(0) { acc, y ->
+ acc or ((image.getPixel(codeStartX, codeStartY + y + 18).and(0xFF) != 0).toInt() shl y)
+ }
+
+ val extInfo = IntArray(15)
+ val props = ExtractedGlyphProps(
+ width, isLowHeight, nudgeX, nudgeY, alignWhere, writeOnTop, stackWhere,
+ hasKernData, isKernYtype, kerningMask, directiveOpcode, directiveArg1, directiveArg2, extInfo
+ )
+
+ // Parse extInfo if needed
+ val extCount = props.requiredExtInfoCount()
+ if (extCount > 0) {
+ for (x in 0 until extCount) {
+ var info = 0
+ for (y in 0..19) {
+ if (image.getPixel(cellX + x, cellY + y).and(0xFF) != 0) {
+ info = info or (1 shl y)
+ }
+ }
+ extInfo[x] = info
+ }
+ }
+
+ // Extract glyph bitmap: all pixels in cell except tag column
+ val bitmapW = cellW - 1 // exclude tag column
+ val bitmap = Array(cellH) { row ->
+ ByteArray(bitmapW) { col ->
+ val px = image.getPixel(cellX + col, cellY + row)
+ if (px and 0xFF != 0) 0xFF.toByte() else 0
+ }
+ }
+
+ result[code] = ExtractedGlyph(code, props, bitmap)
+ }
+ }
+
+ /**
+ * Parse a fixed-width sheet (Hangul, Unihan, Runic, Custom Sym).
+ */
+ private fun parseFixedSheet(
+ image: TgaImage,
+ sheetIndex: Int,
+ cellW: Int,
+ cellH: Int,
+ cols: Int,
+ result: HashMap
+ ) {
+ val codeRangeList = SheetConfig.codeRange[sheetIndex]
+ val fixedWidth = when (sheetIndex) {
+ SheetConfig.SHEET_CUSTOM_SYM -> 20
+ SheetConfig.SHEET_HANGUL -> SheetConfig.W_HANGUL_BASE
+ SheetConfig.SHEET_RUNIC -> 9
+ SheetConfig.SHEET_UNIHAN -> SheetConfig.W_UNIHAN
+ else -> cellW
+ }
+
+ codeRangeList.forEachIndexed { index, code ->
+ val cellX = (index % cols) * cellW
+ val cellY = (index / cols) * cellH
+
+ val bitmap = Array(cellH) { row ->
+ ByteArray(cellW) { col ->
+ val px = image.getPixel(cellX + col, cellY + row)
+ if (px and 0xFF != 0) 0xFF.toByte() else 0
+ }
+ }
+
+ val props = ExtractedGlyphProps(fixedWidth)
+ result[code] = ExtractedGlyph(code, props, bitmap)
+ }
+ }
+
+ /**
+ * Apply fixed-width overrides as in buildWidthTableFixed().
+ */
+ private fun addFixedWidthOverrides(result: HashMap) {
+ // Hangul compat jamo
+ SheetConfig.codeRangeHangulCompat.forEach { code ->
+ if (!result.containsKey(code)) {
+ result[code] = ExtractedGlyph(code, ExtractedGlyphProps(SheetConfig.W_HANGUL_BASE), emptyBitmap())
+ }
+ }
+
+ // Zero-width ranges
+ (0xD800..0xDFFF).forEach { result[it] = ExtractedGlyph(it, ExtractedGlyphProps(0), emptyBitmap()) }
+ (0x100000..0x10FFFF).forEach { result[it] = ExtractedGlyph(it, ExtractedGlyphProps(0), emptyBitmap()) }
+ (0xFFFA0..0xFFFFF).forEach { result[it] = ExtractedGlyph(it, ExtractedGlyphProps(0), emptyBitmap()) }
+
+ // Insular letter
+ result[0x1D79]?.let { /* already in sheet */ } ?: run {
+ result[0x1D79] = ExtractedGlyph(0x1D79, ExtractedGlyphProps(9), emptyBitmap())
+ }
+
+ // Replacement character at U+007F
+ result[0x7F]?.let { existing ->
+ result[0x7F] = existing.copy(props = existing.props.copy(width = 15))
+ }
+
+ // Null char
+ result[0] = ExtractedGlyph(0, ExtractedGlyphProps(0), emptyBitmap())
+ }
+
+ private fun emptyBitmap() = Array(SheetConfig.H) { ByteArray(SheetConfig.W_VAR_INIT) }
+
+ /**
+ * Extracts raw Hangul jamo bitmaps from the Hangul sheet for composition.
+ * Returns a function: (index, row) -> bitmap
+ */
+ fun getHangulJamoBitmaps(): (Int, Int) -> Array {
+ val filename = SheetConfig.fileList[SheetConfig.SHEET_HANGUL]
+ val file = File(assetsDir, filename)
+ if (!file.exists()) {
+ println(" [WARNING] Hangul sheet not found")
+ return { _, _ -> Array(SheetConfig.H) { ByteArray(SheetConfig.W_HANGUL_BASE) } }
+ }
+
+ val image = TgaReader.read(file)
+ val cellW = SheetConfig.W_HANGUL_BASE
+ val cellH = SheetConfig.H
+
+ return { index: Int, row: Int ->
+ val cellX = index * cellW
+ val cellY = row * cellH
+ Array(cellH) { r ->
+ ByteArray(cellW) { c ->
+ val px = image.getPixel(cellX + c, cellY + r)
+ if (px and 0xFF != 0) 0xFF.toByte() else 0
+ }
+ }
+ }
+ }
+}
diff --git a/OTFbuild/src/net/torvald/otfbuild/HangulCompositor.kt b/OTFbuild/src/net/torvald/otfbuild/HangulCompositor.kt
new file mode 100644
index 0000000..5c0da60
--- /dev/null
+++ b/OTFbuild/src/net/torvald/otfbuild/HangulCompositor.kt
@@ -0,0 +1,124 @@
+package net.torvald.otfbuild
+
+import com.kreative.bitsnpicas.BitmapFontGlyph
+
+/**
+ * Composes 11,172 Hangul syllables (U+AC00–U+D7A3) from jamo sprite pieces.
+ * Also composes Hangul Compatibility Jamo (U+3130–U+318F).
+ *
+ * Ported from TerrarumSansBitmap.kt Hangul assembly logic.
+ */
+class HangulCompositor(private val parser: GlyphSheetParser) {
+
+ private val getJamoBitmap = parser.getHangulJamoBitmaps()
+ private val cellW = SheetConfig.W_HANGUL_BASE
+ private val cellH = SheetConfig.H
+
+ /**
+ * Compose all Hangul syllables and compatibility jamo.
+ * @return Map of codepoint to BitmapFontGlyph
+ */
+ fun compose(): Map> {
+ val result = HashMap>(12000)
+
+ // Compose Hangul Compatibility Jamo (U+3130–U+318F)
+ // These are standalone jamo from row 0 of the sheet
+ for (c in 0x3130..0x318F) {
+ val index = c - 0x3130
+ val bitmap = getJamoBitmap(index, 0)
+ val glyph = bitmapToGlyph(bitmap, cellW, cellH)
+ result[c] = glyph to cellW
+ }
+
+ // Compose 11,172 Hangul syllables (U+AC00–U+D7A3)
+ println(" Composing 11,172 Hangul syllables...")
+ for (c in 0xAC00..0xD7A3) {
+ val cInt = c - 0xAC00
+ val indexCho = cInt / (SheetConfig.JUNG_COUNT * SheetConfig.JONG_COUNT)
+ val indexJung = cInt / SheetConfig.JONG_COUNT % SheetConfig.JUNG_COUNT
+ val indexJong = cInt % SheetConfig.JONG_COUNT // 0 = no jongseong
+
+ // Map to jamo codepoints
+ val choCP = 0x1100 + indexCho
+ val jungCP = 0x1161 + indexJung
+ val jongCP = if (indexJong > 0) 0x11A8 + indexJong - 1 else 0
+
+ // Get sheet indices
+ val iCho = SheetConfig.toHangulChoseongIndex(choCP)
+ val iJung = SheetConfig.toHangulJungseongIndex(jungCP) ?: 0
+ val iJong = if (jongCP != 0) SheetConfig.toHangulJongseongIndex(jongCP) ?: 0 else 0
+
+ // Get row positions
+ val choRow = SheetConfig.getHanInitialRow(iCho, iJung, iJong)
+ val jungRow = SheetConfig.getHanMedialRow(iCho, iJung, iJong)
+ val jongRow = SheetConfig.getHanFinalRow(iCho, iJung, iJong)
+
+ // Get jamo bitmaps
+ val choBitmap = getJamoBitmap(iCho, choRow)
+ val jungBitmap = getJamoBitmap(iJung, jungRow)
+
+ // Compose
+ val composed = composeBitmaps(choBitmap, jungBitmap, cellW, cellH)
+ if (indexJong > 0) {
+ val jongBitmap = getJamoBitmap(iJong, jongRow)
+ composeBitmapInto(composed, jongBitmap, cellW, cellH)
+ }
+
+ // Determine advance width
+ val advanceWidth = if (iJung in SheetConfig.hangulPeaksWithExtraWidth) cellW + 1 else cellW
+
+ val glyph = bitmapToGlyph(composed, advanceWidth, cellH)
+ result[c] = glyph to advanceWidth
+ }
+
+ println(" Hangul composition done: ${result.size} glyphs")
+ return result
+ }
+
+ /**
+ * Compose two bitmaps by OR-ing them together.
+ */
+ private fun composeBitmaps(a: Array, b: Array, w: Int, h: Int): Array {
+ val result = Array(h) { row ->
+ ByteArray(w) { col ->
+ val av = a.getOrNull(row)?.getOrNull(col)?.toInt()?.and(0xFF) ?: 0
+ val bv = b.getOrNull(row)?.getOrNull(col)?.toInt()?.and(0xFF) ?: 0
+ if (av != 0 || bv != 0) 0xFF.toByte() else 0
+ }
+ }
+ return result
+ }
+
+ /**
+ * OR a bitmap into an existing one.
+ */
+ private fun composeBitmapInto(target: Array, source: Array, w: Int, h: Int) {
+ for (row in 0 until minOf(h, target.size, source.size)) {
+ for (col in 0 until minOf(w, target[row].size, source[row].size)) {
+ if (source[row][col].toInt() and 0xFF != 0) {
+ target[row][col] = 0xFF.toByte()
+ }
+ }
+ }
+ }
+
+ companion object {
+ /**
+ * Convert a byte[][] bitmap to BitmapFontGlyph.
+ */
+ fun bitmapToGlyph(bitmap: Array, advanceWidth: Int, cellH: Int): BitmapFontGlyph {
+ val h = bitmap.size
+ val w = if (h > 0) bitmap[0].size else 0
+ val glyphData = Array(h) { row ->
+ ByteArray(w) { col -> bitmap[row][col] }
+ }
+ // BitmapFontGlyph(byte[][] glyph, int offset, int width, int ascent)
+ // offset = x offset (left side bearing), width = advance width, ascent = baseline from top
+ val glyph = BitmapFontGlyph()
+ glyph.setGlyph(glyphData)
+ glyph.setXY(0, cellH) // y = ascent from top of em square to baseline
+ glyph.setCharacterWidth(advanceWidth)
+ return glyph
+ }
+ }
+}
diff --git a/OTFbuild/src/net/torvald/otfbuild/KbitxBuilder.kt b/OTFbuild/src/net/torvald/otfbuild/KbitxBuilder.kt
new file mode 100644
index 0000000..ba26ccd
--- /dev/null
+++ b/OTFbuild/src/net/torvald/otfbuild/KbitxBuilder.kt
@@ -0,0 +1,218 @@
+package net.torvald.otfbuild
+
+import com.kreative.bitsnpicas.BitmapFont
+import com.kreative.bitsnpicas.BitmapFontGlyph
+import com.kreative.bitsnpicas.Font
+import com.kreative.bitsnpicas.exporter.KbitxBitmapFontExporter
+import java.io.File
+
+/**
+ * Orchestrates the entire font building pipeline:
+ * 1. Parse all TGA sheets
+ * 2. Create BitmapFont with metrics
+ * 3. Add all extracted glyphs
+ * 4. Compose Hangul syllables
+ * 5. Verify Devanagari/Tamil PUA glyphs
+ * 6. Generate kerning pairs
+ * 7. Export to KBITX
+ */
+class KbitxBuilder(private val assetsDir: String) {
+
+ fun build(outputPath: String) {
+ println("=== Terrarum Sans Bitmap OTF Builder ===")
+ println("Assets: $assetsDir")
+ println("Output: $outputPath")
+ println()
+
+ // 1. Create BitmapFont with metrics
+ println("[1/7] Creating BitmapFont...")
+ val font = BitmapFont(
+ 16, // emAscent: baseline to top of em square
+ 4, // emDescent: baseline to bottom of em square
+ 16, // lineAscent
+ 4, // lineDescent
+ 8, // xHeight
+ 12, // capHeight
+ 0 // lineGap
+ )
+
+ // Set font names
+ font.setName(Font.NAME_FAMILY, "Terrarum Sans Bitmap")
+ font.setName(Font.NAME_STYLE, "Regular")
+ font.setName(Font.NAME_VERSION, "Version 1.0")
+ font.setName(Font.NAME_FAMILY_AND_STYLE, "Terrarum Sans Bitmap Regular")
+ font.setName(Font.NAME_COPYRIGHT, "Copyright (c) 2017-2026 see CONTRIBUTORS.txt")
+ font.setName(Font.NAME_DESCRIPTION, "Bitmap font for Terrarum game engine")
+ font.setName(Font.NAME_LICENSE_DESCRIPTION, "MIT License")
+
+ // 2. Parse all TGA sheets
+ println("[2/7] Parsing TGA sprite sheets...")
+ val parser = GlyphSheetParser(assetsDir)
+ val allGlyphs = parser.parseAll()
+ println(" Parsed ${allGlyphs.size} glyphs from sheets")
+
+ // 3. Add all extracted glyphs to BitmapFont
+ println("[3/7] Adding glyphs to BitmapFont...")
+ var addedCount = 0
+ var skippedCount = 0
+
+ for ((codepoint, extracted) in allGlyphs) {
+ // Skip zero-width control characters and surrogates — don't add empty glyphs
+ if (extracted.props.width <= 0 && codepoint != 0x7F) {
+ // Still add zero-width glyphs that have actual bitmap data
+ val hasPixels = extracted.bitmap.any { row -> row.any { it.toInt() and 0xFF != 0 } }
+ if (!hasPixels) {
+ skippedCount++
+ continue
+ }
+ }
+
+ // Skip internal-only codepoints that would cause issues
+ if (codepoint in 0x100000..0x10FFFF || codepoint in 0xD800..0xDFFF) {
+ skippedCount++
+ continue
+ }
+
+ val glyph = extractedToBitmapFontGlyph(extracted)
+ font.putCharacter(codepoint, glyph)
+ addedCount++
+ }
+ println(" Added $addedCount glyphs, skipped $skippedCount")
+
+ // 4. Compose Hangul syllables
+ println("[4/7] Composing Hangul syllables...")
+ val hangulCompositor = HangulCompositor(parser)
+ val hangulGlyphs = hangulCompositor.compose()
+ for ((codepoint, pair) in hangulGlyphs) {
+ val (glyph, _) = pair
+ font.putCharacter(codepoint, glyph)
+ }
+ println(" Added ${hangulGlyphs.size} Hangul glyphs")
+
+ // 5. Verify Devanagari/Tamil PUA
+ println("[5/7] Verifying Devanagari/Tamil PUA glyphs...")
+ val devaTamilProcessor = DevanagariTamilProcessor()
+ devaTamilProcessor.verify(allGlyphs)
+
+ // 6. Generate kerning pairs
+ println("[6/7] Generating kerning pairs...")
+ val kemingMachine = KemingMachine()
+ val kernPairs = kemingMachine.generateKerningPairs(allGlyphs)
+ for ((pair, offset) in kernPairs) {
+ font.setKernPair(pair, offset)
+ }
+ println(" Added ${kernPairs.size} kerning pairs")
+
+ // 7. Add spacing characters
+ println("[7/7] Finalising...")
+ addSpacingCharacters(font, allGlyphs)
+
+ // Add .notdef from U+007F (replacement character)
+ allGlyphs[0x7F]?.let {
+ val notdefGlyph = extractedToBitmapFontGlyph(it)
+ font.putNamedGlyph(".notdef", notdefGlyph)
+ }
+
+ // Contract glyphs to trim whitespace
+ font.contractGlyphs()
+
+ // Auto-fill any missing name fields
+ font.autoFillNames()
+
+ // Count glyphs
+ val totalGlyphs = font.characters(false).size
+ println()
+ println("Total glyph count: $totalGlyphs")
+
+ // Export
+ println("Exporting to KBITX: $outputPath")
+ val exporter = KbitxBitmapFontExporter()
+ exporter.exportFontToFile(font, File(outputPath))
+
+ println("Done!")
+ }
+
+ private fun extractedToBitmapFontGlyph(extracted: ExtractedGlyph): BitmapFontGlyph {
+ val bitmap = extracted.bitmap
+ val props = extracted.props
+ val h = bitmap.size
+ val w = if (h > 0) bitmap[0].size else 0
+
+ val glyphData = Array(h) { row ->
+ ByteArray(w) { col -> bitmap[row][col] }
+ }
+
+ val glyph = BitmapFontGlyph()
+ glyph.setGlyph(glyphData)
+
+ // y = distance from top of glyph to baseline
+ // For most glyphs this is 16 (baseline at row 16 from top in a 20px cell)
+ // For Unihan: baseline at row 14 (offset by 2 from the 16px cell centred in 20px)
+ val sheetIndex = getSheetIndex(extracted.codepoint)
+ val baseline = when (sheetIndex) {
+ SheetConfig.SHEET_UNIHAN -> 14
+ SheetConfig.SHEET_CUSTOM_SYM -> 16
+ else -> 16
+ }
+ glyph.setXY(0, baseline)
+ glyph.setCharacterWidth(props.width)
+
+ return glyph
+ }
+
+ private fun getSheetIndex(codepoint: Int): Int {
+ // Check fixed sheets first
+ if (codepoint in 0xF0000..0xF005F) return SheetConfig.SHEET_BULGARIAN_VARW
+ if (codepoint in 0xF0060..0xF00BF) return SheetConfig.SHEET_SERBIAN_VARW
+
+ for (i in SheetConfig.codeRange.indices.reversed()) {
+ if (codepoint in SheetConfig.codeRange[i]) return i
+ }
+ return SheetConfig.SHEET_UNKNOWN
+ }
+
+ /**
+ * Add spacing characters as empty glyphs with correct advance widths.
+ */
+ private fun addSpacingCharacters(font: BitmapFont, allGlyphs: Map) {
+ val figWidth = allGlyphs[0x30]?.props?.width ?: 9
+ val punctWidth = allGlyphs[0x2E]?.props?.width ?: 6
+ val em = 12 + 1 // as defined in the original
+
+ fun Int.halveWidth() = this / 2 + 1
+
+ val spacings = mapOf(
+ SheetConfig.NQSP to em.halveWidth(),
+ SheetConfig.MQSP to em,
+ SheetConfig.ENSP to em.halveWidth(),
+ SheetConfig.EMSP to em,
+ SheetConfig.THREE_PER_EMSP to (em / 3 + 1),
+ SheetConfig.QUARTER_EMSP to (em / 4 + 1),
+ SheetConfig.SIX_PER_EMSP to (em / 6 + 1),
+ SheetConfig.FSP to figWidth,
+ SheetConfig.PSP to punctWidth,
+ SheetConfig.THSP to 2,
+ SheetConfig.HSP to 1,
+ SheetConfig.ZWSP to 0,
+ SheetConfig.ZWNJ to 0,
+ SheetConfig.ZWJ to 0,
+ SheetConfig.SHY to 0,
+ )
+
+ for ((cp, width) in spacings) {
+ val glyph = BitmapFontGlyph()
+ glyph.setGlyph(Array(SheetConfig.H) { ByteArray(0) })
+ glyph.setXY(0, 16)
+ glyph.setCharacterWidth(width)
+ font.putCharacter(cp, glyph)
+ }
+
+ // NBSP: same width as space
+ val spaceWidth = allGlyphs[32]?.props?.width ?: 7
+ val nbspGlyph = BitmapFontGlyph()
+ nbspGlyph.setGlyph(Array(SheetConfig.H) { ByteArray(0) })
+ nbspGlyph.setXY(0, 16)
+ nbspGlyph.setCharacterWidth(spaceWidth)
+ font.putCharacter(SheetConfig.NBSP, nbspGlyph)
+ }
+}
diff --git a/OTFbuild/src/net/torvald/otfbuild/KemingMachine.kt b/OTFbuild/src/net/torvald/otfbuild/KemingMachine.kt
new file mode 100644
index 0000000..b168e3b
--- /dev/null
+++ b/OTFbuild/src/net/torvald/otfbuild/KemingMachine.kt
@@ -0,0 +1,120 @@
+package net.torvald.otfbuild
+
+import com.kreative.bitsnpicas.GlyphPair
+
+/**
+ * Generates kerning pairs from shape rules.
+ * Ported from TerrarumSansBitmap.kt "The Keming Machine" section.
+ */
+class KemingMachine {
+
+ private class Ing(val s: String) {
+ private var careBits = 0
+ private var ruleBits = 0
+
+ init {
+ s.forEachIndexed { index, char ->
+ when (char) {
+ '@' -> {
+ careBits = careBits or SheetConfig.kemingBitMask[index]
+ ruleBits = ruleBits or SheetConfig.kemingBitMask[index]
+ }
+ '`' -> {
+ careBits = careBits or SheetConfig.kemingBitMask[index]
+ }
+ }
+ }
+ }
+
+ fun matches(shapeBits: Int) = ((shapeBits and careBits) == ruleBits)
+
+ override fun toString() = "C:${careBits.toString(2).padStart(16, '0')}-R:${ruleBits.toString(2).padStart(16, '0')}"
+ }
+
+ private data class Kem(val first: Ing, val second: Ing, val bb: Int = 2, val yy: Int = 1)
+
+ private val kerningRules: List
+
+ init {
+ val baseRules = listOf(
+ Kem(Ing("_`_@___`__"), Ing("`_`___@___")),
+ Kem(Ing("_@_`___`__"), Ing("`_________")),
+ Kem(Ing("_@_@___`__"), Ing("`___@_@___"), 1, 1),
+ Kem(Ing("_@_@_`_`__"), Ing("`_____@___")),
+ Kem(Ing("___`_`____"), Ing("`___@_`___")),
+ Kem(Ing("___`_`____"), Ing("`_@___`___")),
+ )
+
+ // Automatically create mirrored versions
+ val mirrored = baseRules.map { rule ->
+ val left = rule.first.s
+ val right = rule.second.s
+ val newLeft = StringBuilder()
+ val newRight = StringBuilder()
+
+ for (c in left.indices step 2) {
+ newLeft.append(right[c + 1]).append(right[c])
+ newRight.append(left[c + 1]).append(left[c])
+ }
+
+ Kem(Ing(newLeft.toString()), Ing(newRight.toString()), rule.bb, rule.yy)
+ }
+
+ kerningRules = baseRules + mirrored
+ }
+
+ /**
+ * Generate kerning pairs from all glyphs that have kerning data.
+ * @return Map of GlyphPair to kern offset (negative values = tighter)
+ */
+ fun generateKerningPairs(glyphs: Map): Map {
+ val result = HashMap()
+
+ // Collect all codepoints with kerning data
+ val kernableGlyphs = glyphs.filter { it.value.props.hasKernData }
+
+ if (kernableGlyphs.isEmpty()) {
+ println(" [KemingMachine] No glyphs with kern data found")
+ return result
+ }
+
+ println(" [KemingMachine] ${kernableGlyphs.size} glyphs with kern data")
+
+ // Special rule: lowercase r + dot
+ for (r in SheetConfig.lowercaseRs) {
+ for (d in SheetConfig.dots) {
+ if (glyphs.containsKey(r) && glyphs.containsKey(d)) {
+ result[GlyphPair(r, d)] = -1
+ }
+ }
+ }
+
+ // Apply kerning rules to all pairs
+ val kernCodes = kernableGlyphs.keys.toIntArray()
+ var pairsFound = 0
+
+ for (leftCode in kernCodes) {
+ val leftProps = kernableGlyphs[leftCode]!!.props
+ val maskL = leftProps.kerningMask
+
+ for (rightCode in kernCodes) {
+ val rightProps = kernableGlyphs[rightCode]!!.props
+ val maskR = rightProps.kerningMask
+
+ for (rule in kerningRules) {
+ if (rule.first.matches(maskL) && rule.second.matches(maskR)) {
+ val contraction = if (leftProps.isKernYtype || rightProps.isKernYtype) rule.yy else rule.bb
+ if (contraction > 0) {
+ result[GlyphPair(leftCode, rightCode)] = -contraction
+ pairsFound++
+ }
+ break // first matching rule wins
+ }
+ }
+ }
+ }
+
+ println(" [KemingMachine] Generated $pairsFound kerning pairs (+ ${SheetConfig.lowercaseRs.size * SheetConfig.dots.size} r-dot pairs)")
+ return result
+ }
+}
diff --git a/OTFbuild/src/net/torvald/otfbuild/Main.kt b/OTFbuild/src/net/torvald/otfbuild/Main.kt
new file mode 100644
index 0000000..1fbb891
--- /dev/null
+++ b/OTFbuild/src/net/torvald/otfbuild/Main.kt
@@ -0,0 +1,7 @@
+package net.torvald.otfbuild
+
+fun main(args: Array) {
+ val assetsDir = args.getOrElse(0) { "src/assets" }
+ val outputPath = args.getOrElse(1) { "OTFbuild/TerrarumSansBitmap.kbitx" }
+ KbitxBuilder(assetsDir).build(outputPath)
+}
diff --git a/OTFbuild/src/net/torvald/otfbuild/SheetConfig.kt b/OTFbuild/src/net/torvald/otfbuild/SheetConfig.kt
new file mode 100644
index 0000000..14bda7e
--- /dev/null
+++ b/OTFbuild/src/net/torvald/otfbuild/SheetConfig.kt
@@ -0,0 +1,377 @@
+package net.torvald.otfbuild
+
+typealias CodePoint = Int
+
+/**
+ * Ported from TerrarumSansBitmap.kt companion object.
+ * All sheet definitions, code ranges, index functions, and font metric constants.
+ */
+object SheetConfig {
+
+ // Font metrics
+ const val H = 20
+ const val H_UNIHAN = 16
+ const val W_HANGUL_BASE = 13
+ const val W_UNIHAN = 16
+ const val W_LATIN_WIDE = 9
+ const val W_VAR_INIT = 15
+ const val W_WIDEVAR_INIT = 31
+ const val HGAP_VAR = 1
+ const val SIZE_CUSTOM_SYM = 20
+
+ const val H_DIACRITICS = 3
+ const val H_STACKUP_LOWERCASE_SHIFTDOWN = 4
+ const val H_OVERLAY_LOWERCASE_SHIFTDOWN = 2
+
+ const val LINE_HEIGHT = 24
+
+ // Sheet indices
+ const val SHEET_ASCII_VARW = 0
+ const val SHEET_HANGUL = 1
+ const val SHEET_EXTA_VARW = 2
+ const val SHEET_EXTB_VARW = 3
+ const val SHEET_KANA = 4
+ const val SHEET_CJK_PUNCT = 5
+ const val SHEET_UNIHAN = 6
+ const val SHEET_CYRILIC_VARW = 7
+ const val SHEET_HALFWIDTH_FULLWIDTH_VARW = 8
+ const val SHEET_UNI_PUNCT_VARW = 9
+ const val SHEET_GREEK_VARW = 10
+ const val SHEET_THAI_VARW = 11
+ const val SHEET_HAYEREN_VARW = 12
+ const val SHEET_KARTULI_VARW = 13
+ const val SHEET_IPA_VARW = 14
+ const val SHEET_RUNIC = 15
+ const val SHEET_LATIN_EXT_ADD_VARW = 16
+ const val SHEET_CUSTOM_SYM = 17
+ const val SHEET_BULGARIAN_VARW = 18
+ const val SHEET_SERBIAN_VARW = 19
+ const val SHEET_TSALAGI_VARW = 20
+ const val SHEET_PHONETIC_EXT_VARW = 21
+ const val SHEET_DEVANAGARI_VARW = 22
+ const val SHEET_KARTULI_CAPS_VARW = 23
+ const val SHEET_DIACRITICAL_MARKS_VARW = 24
+ const val SHEET_GREEK_POLY_VARW = 25
+ const val SHEET_EXTC_VARW = 26
+ const val SHEET_EXTD_VARW = 27
+ const val SHEET_CURRENCIES_VARW = 28
+ const val SHEET_INTERNAL_VARW = 29
+ const val SHEET_LETTERLIKE_MATHS_VARW = 30
+ const val SHEET_ENCLOSED_ALPHNUM_SUPL_VARW = 31
+ const val SHEET_TAMIL_VARW = 32
+ const val SHEET_BENGALI_VARW = 33
+ const val SHEET_BRAILLE_VARW = 34
+ const val SHEET_SUNDANESE_VARW = 35
+ const val SHEET_DEVANAGARI2_INTERNAL_VARW = 36
+ const val SHEET_CODESTYLE_ASCII_VARW = 37
+ const val SHEET_ALPHABETIC_PRESENTATION_FORMS = 38
+ const val SHEET_HENTAIGANA_VARW = 39
+
+ const val SHEET_UNKNOWN = 254
+
+ val fileList = arrayOf(
+ "ascii_variable.tga",
+ "hangul_johab.tga",
+ "latinExtA_variable.tga",
+ "latinExtB_variable.tga",
+ "kana_variable.tga",
+ "cjkpunct_variable.tga",
+ "wenquanyi.tga",
+ "cyrilic_variable.tga",
+ "halfwidth_fullwidth_variable.tga",
+ "unipunct_variable.tga",
+ "greek_variable.tga",
+ "thai_variable.tga",
+ "hayeren_variable.tga",
+ "kartuli_variable.tga",
+ "ipa_ext_variable.tga",
+ "futhark.tga",
+ "latinExt_additional_variable.tga",
+ "puae000-e0ff.tga",
+ "cyrilic_bulgarian_variable.tga",
+ "cyrilic_serbian_variable.tga",
+ "tsalagi_variable.tga",
+ "phonetic_extensions_variable.tga",
+ "devanagari_variable.tga",
+ "kartuli_allcaps_variable.tga",
+ "diacritical_marks_variable.tga",
+ "greek_polytonic_xyswap_variable.tga",
+ "latinExtC_variable.tga",
+ "latinExtD_variable.tga",
+ "currencies_variable.tga",
+ "internal_variable.tga",
+ "letterlike_symbols_variable.tga",
+ "enclosed_alphanumeric_supplement_variable.tga",
+ "tamil_extrawide_variable.tga",
+ "bengali_variable.tga",
+ "braille_variable.tga",
+ "sundanese_variable.tga",
+ "devanagari_internal_extrawide_variable.tga",
+ "pua_codestyle_ascii_variable.tga",
+ "alphabetic_presentation_forms_extrawide_variable.tga",
+ "hentaigana_variable.tga",
+ )
+
+ val codeRange: Array> = arrayOf(
+ (0..0xFF).toList(),
+ (0x1100..0x11FF).toList() + (0xA960..0xA97F).toList() + (0xD7B0..0xD7FF).toList(),
+ (0x100..0x17F).toList(),
+ (0x180..0x24F).toList(),
+ (0x3040..0x30FF).toList() + (0x31F0..0x31FF).toList(),
+ (0x3000..0x303F).toList(),
+ (0x3400..0x9FFF).toList(),
+ (0x400..0x52F).toList(),
+ (0xFF00..0xFFFF).toList(),
+ (0x2000..0x209F).toList(),
+ (0x370..0x3CE).toList(),
+ (0xE00..0xE5F).toList(),
+ (0x530..0x58F).toList(),
+ (0x10D0..0x10FF).toList(),
+ (0x250..0x2FF).toList(),
+ (0x16A0..0x16FF).toList(),
+ (0x1E00..0x1EFF).toList(),
+ (0xE000..0xE0FF).toList(),
+ (0xF0000..0xF005F).toList(),
+ (0xF0060..0xF00BF).toList(),
+ (0x13A0..0x13F5).toList(),
+ (0x1D00..0x1DBF).toList(),
+ (0x900..0x97F).toList() + (0xF0100..0xF04FF).toList(),
+ (0x1C90..0x1CBF).toList(),
+ (0x300..0x36F).toList(),
+ (0x1F00..0x1FFF).toList(),
+ (0x2C60..0x2C7F).toList(),
+ (0xA720..0xA7FF).toList(),
+ (0x20A0..0x20CF).toList(),
+ (0xFFE00..0xFFF9F).toList(),
+ (0x2100..0x214F).toList(),
+ (0x1F100..0x1F1FF).toList(),
+ (0x0B80..0x0BFF).toList() + (0xF00C0..0xF00FF).toList(),
+ (0x980..0x9FF).toList(),
+ (0x2800..0x28FF).toList(),
+ (0x1B80..0x1BBF).toList() + (0x1CC0..0x1CCF).toList() + (0xF0500..0xF050F).toList(),
+ (0xF0110..0xF012F).toList(),
+ (0xF0520..0xF057F).toList(),
+ (0xFB00..0xFB17).toList(),
+ (0x1B000..0x1B16F).toList(),
+ )
+
+ val codeRangeHangulCompat = 0x3130..0x318F
+
+ val altCharsetCodepointOffsets = intArrayOf(
+ 0,
+ 0xF0000 - 0x400, // Bulgarian
+ 0xF0060 - 0x400, // Serbian
+ 0xF0520 - 0x20, // Codestyle
+ )
+
+ val altCharsetCodepointDomains = arrayOf(
+ 0..0x10FFFF,
+ 0x400..0x45F,
+ 0x400..0x45F,
+ 0x20..0x7F,
+ )
+
+ // Unicode spacing characters
+ const val NQSP = 0x2000
+ const val MQSP = 0x2001
+ const val ENSP = 0x2002
+ const val EMSP = 0x2003
+ const val THREE_PER_EMSP = 0x2004
+ const val QUARTER_EMSP = 0x2005
+ const val SIX_PER_EMSP = 0x2006
+ const val FSP = 0x2007
+ const val PSP = 0x2008
+ const val THSP = 0x2009
+ const val HSP = 0x200A
+ const val ZWSP = 0x200B
+ const val ZWNJ = 0x200C
+ const val ZWJ = 0x200D
+ const val SHY = 0xAD
+ const val NBSP = 0xA0
+ const val OBJ = 0xFFFC
+
+ const val FIXED_BLOCK_1 = 0xFFFD0
+ const val MOVABLE_BLOCK_M1 = 0xFFFE0
+ const val MOVABLE_BLOCK_1 = 0xFFFF0
+
+ const val CHARSET_OVERRIDE_DEFAULT = 0xFFFC0
+ const val CHARSET_OVERRIDE_BG_BG = 0xFFFC1
+ const val CHARSET_OVERRIDE_SR_SR = 0xFFFC2
+ const val CHARSET_OVERRIDE_CODESTYLE = 0xFFFC3
+
+ // Sheet type detection
+ fun isVariable(filename: String) = filename.endsWith("_variable.tga")
+ fun isXYSwapped(filename: String) = filename.contains("xyswap", ignoreCase = true)
+ fun isExtraWide(filename: String) = filename.contains("extrawide", ignoreCase = true)
+
+ /** Returns the cell width for a given sheet index. */
+ fun getCellWidth(sheetIndex: Int): Int = when {
+ isExtraWide(fileList[sheetIndex]) -> W_WIDEVAR_INIT
+ isVariable(fileList[sheetIndex]) -> W_VAR_INIT
+ sheetIndex == SHEET_UNIHAN -> W_UNIHAN
+ sheetIndex == SHEET_HANGUL -> W_HANGUL_BASE
+ sheetIndex == SHEET_CUSTOM_SYM -> SIZE_CUSTOM_SYM
+ sheetIndex == SHEET_RUNIC -> W_LATIN_WIDE
+ else -> W_VAR_INIT
+ }
+
+ /** Returns the cell height for a given sheet index. */
+ fun getCellHeight(sheetIndex: Int): Int = when (sheetIndex) {
+ SHEET_UNIHAN -> H_UNIHAN
+ SHEET_CUSTOM_SYM -> SIZE_CUSTOM_SYM
+ else -> H
+ }
+
+ /** Number of columns per row for the sheet. */
+ fun getColumns(sheetIndex: Int): Int = when (sheetIndex) {
+ SHEET_UNIHAN -> 256
+ else -> 16
+ }
+
+ // Index functions (X position in sheet)
+ fun indexX(c: CodePoint): Int = c % 16
+ fun unihanIndexX(c: CodePoint): Int = (c - 0x3400) % 256
+
+ // Index functions (Y position in sheet) — per sheet type
+ fun indexY(sheetIndex: Int, c: CodePoint): Int = when (sheetIndex) {
+ SHEET_ASCII_VARW -> c / 16
+ SHEET_UNIHAN -> unihanIndexY(c)
+ SHEET_EXTA_VARW -> (c - 0x100) / 16
+ SHEET_EXTB_VARW -> (c - 0x180) / 16
+ SHEET_KANA -> kanaIndexY(c)
+ SHEET_CJK_PUNCT -> (c - 0x3000) / 16
+ SHEET_CYRILIC_VARW -> (c - 0x400) / 16
+ SHEET_HALFWIDTH_FULLWIDTH_VARW -> (c - 0xFF00) / 16
+ SHEET_UNI_PUNCT_VARW -> (c - 0x2000) / 16
+ SHEET_GREEK_VARW -> (c - 0x370) / 16
+ SHEET_THAI_VARW -> (c - 0xE00) / 16
+ SHEET_CUSTOM_SYM -> (c - 0xE000) / 16
+ SHEET_HAYEREN_VARW -> (c - 0x530) / 16
+ SHEET_KARTULI_VARW -> (c - 0x10D0) / 16
+ SHEET_IPA_VARW -> (c - 0x250) / 16
+ SHEET_RUNIC -> (c - 0x16A0) / 16
+ SHEET_LATIN_EXT_ADD_VARW -> (c - 0x1E00) / 16
+ SHEET_BULGARIAN_VARW -> (c - 0xF0000) / 16
+ SHEET_SERBIAN_VARW -> (c - 0xF0060) / 16
+ SHEET_TSALAGI_VARW -> (c - 0x13A0) / 16
+ SHEET_PHONETIC_EXT_VARW -> (c - 0x1D00) / 16
+ SHEET_DEVANAGARI_VARW -> devanagariIndexY(c)
+ SHEET_KARTULI_CAPS_VARW -> (c - 0x1C90) / 16
+ SHEET_DIACRITICAL_MARKS_VARW -> (c - 0x300) / 16
+ SHEET_GREEK_POLY_VARW -> (c - 0x1F00) / 16
+ SHEET_EXTC_VARW -> (c - 0x2C60) / 16
+ SHEET_EXTD_VARW -> (c - 0xA720) / 16
+ SHEET_CURRENCIES_VARW -> (c - 0x20A0) / 16
+ SHEET_INTERNAL_VARW -> (c - 0xFFE00) / 16
+ SHEET_LETTERLIKE_MATHS_VARW -> (c - 0x2100) / 16
+ SHEET_ENCLOSED_ALPHNUM_SUPL_VARW -> (c - 0x1F100) / 16
+ SHEET_TAMIL_VARW -> tamilIndexY(c)
+ SHEET_BENGALI_VARW -> (c - 0x980) / 16
+ SHEET_BRAILLE_VARW -> (c - 0x2800) / 16
+ SHEET_SUNDANESE_VARW -> sundaneseIndexY(c)
+ SHEET_DEVANAGARI2_INTERNAL_VARW -> (c - 0xF0110) / 16
+ SHEET_CODESTYLE_ASCII_VARW -> (c - 0xF0520) / 16
+ SHEET_ALPHABETIC_PRESENTATION_FORMS -> (c - 0xFB00) / 16
+ SHEET_HENTAIGANA_VARW -> (c - 0x1B000) / 16
+ SHEET_HANGUL -> 0 // Hangul uses special row logic
+ else -> c / 16
+ }
+
+ private fun kanaIndexY(c: CodePoint): Int =
+ if (c in 0x31F0..0x31FF) 12
+ else (c - 0x3040) / 16
+
+ private fun unihanIndexY(c: CodePoint): Int = (c - 0x3400) / 256
+
+ private fun devanagariIndexY(c: CodePoint): Int =
+ (if (c < 0xF0000) (c - 0x0900) else (c - 0xF0080)) / 16
+
+ private fun tamilIndexY(c: CodePoint): Int =
+ (if (c < 0xF0000) (c - 0x0B80) else (c - 0xF0040)) / 16
+
+ private fun sundaneseIndexY(c: CodePoint): Int =
+ (if (c >= 0xF0500) (c - 0xF04B0) else if (c < 0x1BC0) (c - 0x1B80) else (c - 0x1C80)) / 16
+
+ // Hangul constants
+ const val JUNG_COUNT = 21
+ const val JONG_COUNT = 28
+
+ // Hangul shape arrays (sorted)
+ val jungseongI = sortedSetOf(21, 61)
+ val jungseongOU = sortedSetOf(9, 13, 14, 18, 34, 35, 39, 45, 51, 53, 54, 64, 73, 80, 83)
+ val jungseongOUComplex = (listOf(10, 11, 16) + (22..33).toList() + listOf(36, 37, 38) + (41..44).toList() +
+ (46..50).toList() + (56..59).toList() + listOf(63) + (67..72).toList() + (74..79).toList() +
+ (81..83).toList() + (85..91).toList() + listOf(93, 94)).toSortedSet()
+ val jungseongRightie = sortedSetOf(2, 4, 6, 8, 11, 16, 32, 33, 37, 42, 44, 48, 50, 71, 72, 75, 78, 79, 83, 86, 87, 88, 94)
+ val jungseongOEWI = sortedSetOf(12, 15, 17, 40, 52, 55, 89, 90, 91)
+ val jungseongEU = sortedSetOf(19, 62, 66)
+ val jungseongYI = sortedSetOf(20, 60, 65)
+ val jungseongUU = sortedSetOf(14, 15, 16, 17, 18, 27, 30, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, 67, 68, 73, 77, 78, 79, 80, 81, 82, 83, 84, 91)
+ val jungseongWide = (jungseongOU.toList() + jungseongEU.toList()).toSortedSet()
+ val choseongGiyeoks = sortedSetOf(0, 1, 15, 23, 30, 34, 45, 51, 56, 65, 82, 90, 100, 101, 110, 111, 115)
+ val hangulPeaksWithExtraWidth = sortedSetOf(2, 4, 6, 8, 11, 16, 32, 33, 37, 42, 44, 48, 50, 71, 75, 78, 79, 83, 86, 87, 88, 94)
+
+ val giyeokRemapping = hashMapOf(
+ 5 to 19, 6 to 20, 7 to 21, 8 to 22, 11 to 23, 12 to 24,
+ )
+
+ fun isHangulChoseong(c: CodePoint) = c in 0x1100..0x115F || c in 0xA960..0xA97F
+ fun isHangulJungseong(c: CodePoint) = c in 0x1160..0x11A7 || c in 0xD7B0..0xD7C6
+ fun isHangulJongseong(c: CodePoint) = c in 0x11A8..0x11FF || c in 0xD7CB..0xD7FB
+ fun isHangulCompat(c: CodePoint) = c in codeRangeHangulCompat
+
+ fun toHangulChoseongIndex(c: CodePoint): Int =
+ if (c in 0x1100..0x115F) c - 0x1100
+ else if (c in 0xA960..0xA97F) c - 0xA960 + 96
+ else throw IllegalArgumentException("Not a choseong: U+${c.toString(16)}")
+
+ fun toHangulJungseongIndex(c: CodePoint): Int? =
+ if (c in 0x1160..0x11A7) c - 0x1160
+ else if (c in 0xD7B0..0xD7C6) c - 0xD7B0 + 72
+ else null
+
+ fun toHangulJongseongIndex(c: CodePoint): Int? =
+ if (c in 0x11A8..0x11FF) c - 0x11A8 + 1
+ else if (c in 0xD7CB..0xD7FB) c - 0xD7CB + 88 + 1
+ else null
+
+ fun getHanInitialRow(i: Int, p: Int, f: Int): Int {
+ var ret = when {
+ p in jungseongI -> 3
+ p in jungseongOEWI -> 11
+ p in jungseongOUComplex -> 7
+ p in jungseongOU -> 5
+ p in jungseongEU -> 9
+ p in jungseongYI -> 13
+ else -> 1
+ }
+ if (f != 0) ret += 1
+ return if (p in jungseongUU && i in choseongGiyeoks) {
+ giyeokRemapping[ret] ?: throw NullPointerException("i=$i p=$p f=$f ret=$ret")
+ } else ret
+ }
+
+ fun getHanMedialRow(i: Int, p: Int, f: Int): Int = if (f == 0) 15 else 16
+
+ fun getHanFinalRow(i: Int, p: Int, f: Int): Int =
+ if (p !in jungseongRightie) 17 else 18
+
+ // Kerning constants
+ val kemingBitMask: IntArray = intArrayOf(7, 6, 5, 4, 3, 2, 1, 0, 15, 14).map { 1 shl it }.toIntArray()
+
+ // Special characters for r+dot kerning
+ val lowercaseRs = sortedSetOf(0x72, 0x155, 0x157, 0x159, 0x211, 0x213, 0x27c, 0x1e59, 0x1e58, 0x1e5f)
+ val dots = sortedSetOf(0x2c, 0x2e)
+
+ // Devanagari internal encoding
+ fun Int.toDevaInternal(): Int {
+ if (this in 0x0915..0x0939) return this - 0x0915 + 0xF0140
+ else if (this in 0x0958..0x095F) return devanagariUnicodeNuqtaTable[this - 0x0958]
+ else throw IllegalArgumentException("No internal form for U+${this.toString(16)}")
+ }
+
+ val devanagariUnicodeNuqtaTable = intArrayOf(0xF0170, 0xF0171, 0xF0172, 0xF0177, 0xF017C, 0xF017D, 0xF0186, 0xF018A)
+
+ val devanagariConsonants = ((0x0915..0x0939).toList() + (0x0958..0x095F).toList() + (0x0978..0x097F).toList() +
+ (0xF0140..0xF04FF).toList() + (0xF0106..0xF0109).toList()).toHashSet()
+}
diff --git a/OTFbuild/src/net/torvald/otfbuild/TgaReader.kt b/OTFbuild/src/net/torvald/otfbuild/TgaReader.kt
new file mode 100644
index 0000000..f727daa
--- /dev/null
+++ b/OTFbuild/src/net/torvald/otfbuild/TgaReader.kt
@@ -0,0 +1,80 @@
+package net.torvald.otfbuild
+
+import java.io.File
+import java.io.InputStream
+
+/**
+ * Simple TGA reader for uncompressed true-colour images (Type 2).
+ * Returns RGBA8888 pixel data.
+ */
+class TgaImage(val width: Int, val height: Int, val pixels: IntArray) {
+ /** Get pixel at (x, y) as RGBA8888. */
+ fun getPixel(x: Int, y: Int): Int {
+ if (x < 0 || x >= width || y < 0 || y >= height) return 0
+ return pixels[y * width + x]
+ }
+}
+
+object TgaReader {
+
+ fun read(file: File): TgaImage = read(file.inputStream())
+
+ fun read(input: InputStream): TgaImage {
+ val data = input.use { it.readBytes() }
+ var pos = 0
+
+ fun u8() = data[pos++].toInt() and 0xFF
+ fun u16() = u8() or (u8() shl 8)
+
+ val idLength = u8()
+ val colourMapType = u8()
+ val imageType = u8()
+
+ // colour map spec (5 bytes)
+ u16(); u16(); u8()
+
+ // image spec
+ val xOrigin = u16()
+ val yOrigin = u16()
+ val width = u16()
+ val height = u16()
+ val bitsPerPixel = u8()
+ val descriptor = u8()
+
+ val topToBottom = (descriptor and 0x20) != 0
+ val bytesPerPixel = bitsPerPixel / 8
+
+ // skip ID
+ pos += idLength
+
+ // skip colour map
+ if (colourMapType != 0) {
+ throw UnsupportedOperationException("Colour-mapped TGA not supported")
+ }
+
+ if (imageType != 2) {
+ throw UnsupportedOperationException("Only uncompressed true-colour TGA is supported (type 2), got type $imageType")
+ }
+
+ if (bytesPerPixel !in 3..4) {
+ throw UnsupportedOperationException("Only 24-bit or 32-bit TGA supported, got ${bitsPerPixel}-bit")
+ }
+
+ val pixels = IntArray(width * height)
+
+ for (row in 0 until height) {
+ val y = if (topToBottom) row else (height - 1 - row)
+ for (x in 0 until width) {
+ val b = data[pos++].toInt() and 0xFF
+ val g = data[pos++].toInt() and 0xFF
+ val r = data[pos++].toInt() and 0xFF
+ val a = if (bytesPerPixel == 4) data[pos++].toInt() and 0xFF else 0xFF
+
+ // Store as RGBA8888
+ pixels[y * width + x] = (r shl 24) or (g shl 16) or (b shl 8) or a
+ }
+ }
+
+ return TgaImage(width, height, pixels)
+ }
+}
diff --git a/src/net/torvald/terrarumsansbitmap/gdx/TerrarumSansBitmap.kt b/src/net/torvald/terrarumsansbitmap/gdx/TerrarumSansBitmap.kt
index 34ae184..9bfea09 100755
--- a/src/net/torvald/terrarumsansbitmap/gdx/TerrarumSansBitmap.kt
+++ b/src/net/torvald/terrarumsansbitmap/gdx/TerrarumSansBitmap.kt
@@ -3022,7 +3022,7 @@ class TerrarumSansBitmap(
- // The "Keming" Machine //
+ // The Keming Machine //
private val kemingBitMask: IntArray = intArrayOf(7,6,5,4,3,2,1,0,15,14).map { 1 shl it }.toIntArray()