TTF build using fontforge

This commit is contained in:
minjaesong
2026-02-23 18:32:03 +09:00
parent 208466bbb2
commit 5e2cacd491
20 changed files with 2213 additions and 1486 deletions

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager">
<output url="file://$MODULE_DIR$/out/production/OTFbuild" />
<output-test url="file://$MODULE_DIR$/out/test/OTFbuild" />
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/out" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="KotlinJavaRuntime" level="project" />
<orderEntry type="module-library">
<library>
<CLASSES>
<root url="jar://$MODULE_DIR$/bitsnpicas_runtime/BitsNPicas.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES>
<root url="file://$MODULE_DIR$/bitsnpicas_source_codes/src" />
</SOURCES>
</library>
</orderEntry>
</component>
</module>

91
OTFbuild/bitmap_tracer.py Normal file
View File

@@ -0,0 +1,91 @@
"""
Convert 1-bit bitmap arrays to TrueType quadratic outlines.
Each set pixel becomes part of a rectangle contour drawn clockwise.
Adjacent identical horizontal runs are merged vertically into rectangles.
Scale: x_left = col * SCALE, y_top = (BASELINE_ROW - row) * SCALE
where BASELINE_ROW = 16 (ascent in pixels).
"""
from typing import Dict, List, Tuple
import sheet_config as SC
SCALE = SC.SCALE
BASELINE_ROW = 16 # pixels from top to baseline
def trace_bitmap(bitmap, glyph_width_px):
"""
Convert a bitmap to a list of rectangle contours.
Each rectangle is ((x0, y0), (x1, y1)) in font units, where:
- (x0, y0) is bottom-left
- (x1, y1) is top-right
Returns list of (x0, y0, x1, y1) tuples representing rectangles.
"""
if not bitmap or not bitmap[0]:
return []
h = len(bitmap)
w = len(bitmap[0])
# Step 1: Find horizontal runs per row
runs = [] # list of (row, col_start, col_end)
for row in range(h):
col = 0
while col < w:
if bitmap[row][col]:
start = col
while col < w and bitmap[row][col]:
col += 1
runs.append((row, start, col))
else:
col += 1
# Step 2: Merge vertically adjacent identical runs into rectangles
rects = [] # (row_start, row_end, col_start, col_end)
used = [False] * len(runs)
for i, (row, cs, ce) in enumerate(runs):
if used[i]:
continue
# Try to extend this run downward
row_end = row + 1
j = i + 1
while j < len(runs):
r2, cs2, ce2 = runs[j]
if r2 > row_end:
break
if r2 == row_end and cs2 == cs and ce2 == ce and not used[j]:
used[j] = True
row_end = r2 + 1
j += 1
rects.append((row, row_end, cs, ce))
# Step 3: Convert to font coordinates
contours = []
for row_start, row_end, col_start, col_end in rects:
x0 = col_start * SCALE
x1 = col_end * SCALE
y_top = (BASELINE_ROW - row_start) * SCALE
y_bottom = (BASELINE_ROW - row_end) * SCALE
contours.append((x0, y_bottom, x1, y_top))
return contours
def draw_glyph_to_pen(contours, pen):
"""
Draw rectangle contours to a TTGlyphPen or similar pen.
Each rectangle is drawn as a clockwise closed contour (4 on-curve points).
"""
for x0, y0, x1, y1 in contours:
# Clockwise: bottom-left -> top-left -> top-right -> bottom-right
pen.moveTo((x0, y0))
pen.lineTo((x0, y1))
pen.lineTo((x1, y1))
pen.lineTo((x1, y0))
pen.closePath()

75
OTFbuild/build_font.py Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""
Terrarum Sans Bitmap OTF Builder v2 — Python + fonttools
Builds a TTF font with both vector-traced outlines (TrueType glyf)
and embedded bitmap strike (EBDT/EBLC) from TGA sprite sheets.
Usage:
python3 OTFbuild/build_font.py src/assets -o OTFbuild/TerrarumSansBitmap.ttf
Options:
--no-bitmap Skip EBDT/EBLC bitmap strike
--no-features Skip GSUB/GPOS OpenType features
"""
import argparse
import sys
import os
# Add OTFbuild dir to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from font_builder import build_font
def main():
parser = argparse.ArgumentParser(
description="Build Terrarum Sans Bitmap TTF from TGA sprite sheets"
)
parser.add_argument(
"assets_dir",
help="Path to assets directory containing TGA sprite sheets"
)
parser.add_argument(
"-o", "--output",
default="OTFbuild/TerrarumSansBitmap.ttf",
help="Output TTF file path (default: OTFbuild/TerrarumSansBitmap.ttf)"
)
parser.add_argument(
"--no-bitmap",
action="store_true",
help="Skip EBDT/EBLC bitmap strike"
)
parser.add_argument(
"--no-features",
action="store_true",
help="Skip GSUB/GPOS OpenType features"
)
args = parser.parse_args()
if not os.path.isdir(args.assets_dir):
print(f"Error: assets directory not found: {args.assets_dir}", file=sys.stderr)
sys.exit(1)
# Ensure output directory exists
output_dir = os.path.dirname(args.output)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
print(f"Terrarum Sans Bitmap OTF Builder v2")
print(f" Assets: {args.assets_dir}")
print(f" Output: {args.output}")
print()
build_font(
assets_dir=args.assets_dir,
output_path=args.output,
no_bitmap=args.no_bitmap,
no_features=args.no_features,
)
if __name__ == "__main__":
main()

View File

@@ -1,80 +0,0 @@
#!/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"

422
OTFbuild/font_builder.py Normal file
View File

@@ -0,0 +1,422 @@
"""
Orchestrate fonttools TTFont assembly.
1. Parse all sheets -> glyphs dict
2. Compose Hangul -> add to dict
3. Create glyph order and cmap
4. Trace all bitmaps -> glyf table
5. Set hmtx, hhea, OS/2, head, name, post
6. Generate and compile OpenType features via feaLib
7. Add EBDT/EBLC bitmap strike at ppem=20
8. Save TTF
"""
import time
from typing import Dict
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.feaLib.builder import addOpenTypeFeatures
from fontTools.ttLib import TTFont
import io
from glyph_parser import ExtractedGlyph, parse_all_sheets
from hangul import compose_hangul
from bitmap_tracer import trace_bitmap, draw_glyph_to_pen, SCALE, BASELINE_ROW
from keming_machine import generate_kerning_pairs
from opentype_features import generate_features, glyph_name
import sheet_config as SC
# Codepoints that get cmap entries (user-visible)
# PUA forms used internally by GSUB get glyphs but NO cmap entries
_PUA_CMAP_RANGES = [
range(0xE000, 0xE100), # Custom symbols
range(0xF0520, 0xF0580), # Codestyle ASCII
]
def _should_have_cmap(cp):
"""Determine if a codepoint should have a cmap entry."""
# Standard Unicode characters always get cmap entries
if cp < 0xE000:
return True
# Custom sym PUA range
if 0xE000 <= cp <= 0xE0FF:
return True
# Codestyle PUA
if 0xF0520 <= cp <= 0xF057F:
return True
# Hangul syllables
if 0xAC00 <= cp <= 0xD7A3:
return True
# Hangul compat jamo
if 0x3130 <= cp <= 0x318F:
return True
# SMP characters (Enclosed Alphanumeric Supplement, Hentaigana, etc.)
if 0x1F100 <= cp <= 0x1F1FF:
return True
if 0x1B000 <= cp <= 0x1B16F:
return True
# Everything in standard Unicode ranges (up to 0xFFFF plus SMP)
if cp <= 0xFFFF:
return True
# Internal PUA forms (Devanagari, Tamil, Sundanese, Bulgarian, Serbian internals)
# These are GSUB-only and should NOT have cmap entries
if 0xF0000 <= cp <= 0xF051F:
return False
# Internal control characters
if 0xFFE00 <= cp <= 0xFFFFF:
return False
return True
def build_font(assets_dir, output_path, no_bitmap=False, no_features=False):
"""Build the complete TTF font."""
t0 = time.time()
# Step 1: Parse all sheets
print("Step 1: Parsing glyph sheets...")
glyphs = parse_all_sheets(assets_dir)
print(f" Parsed {len(glyphs)} glyphs from sheets")
# Step 2: Compose Hangul
print("Step 2: Composing Hangul syllables...")
hangul_glyphs = compose_hangul(assets_dir)
glyphs.update(hangul_glyphs)
print(f" Total glyphs after Hangul: {len(glyphs)}")
# Step 3: Create glyph order and cmap
print("Step 3: Building glyph order and cmap...")
glyph_order = [".notdef"]
cmap = {}
glyph_set = set()
# Sort codepoints for deterministic output
sorted_cps = sorted(glyphs.keys())
for cp in sorted_cps:
g = glyphs[cp]
if g.props.is_illegal:
continue
name = glyph_name(cp)
if name == ".notdef":
continue
if name in glyph_set:
continue
glyph_order.append(name)
glyph_set.add(name)
if _should_have_cmap(cp):
cmap[cp] = name
print(f" Glyph order: {len(glyph_order)} glyphs, cmap: {len(cmap)} entries")
# Step 4: Build font with fonttools
print("Step 4: Building font tables...")
fb = FontBuilder(SC.UNITS_PER_EM, isTTF=True)
fb.setupGlyphOrder(glyph_order)
# Build cmap
fb.setupCharacterMap(cmap)
# Step 5: Trace bitmaps -> glyf table
print("Step 5: Tracing bitmaps to outlines...")
glyph_table = {}
pen = TTGlyphPen(None)
# .notdef glyph (empty box)
pen.moveTo((0, 0))
pen.lineTo((0, SC.ASCENT))
pen.lineTo((SC.UNITS_PER_EM // 2, SC.ASCENT))
pen.lineTo((SC.UNITS_PER_EM // 2, 0))
pen.closePath()
# Inner box
_m = 2 * SCALE
pen.moveTo((_m, _m))
pen.lineTo((SC.UNITS_PER_EM // 2 - _m, _m))
pen.lineTo((SC.UNITS_PER_EM // 2 - _m, SC.ASCENT - _m))
pen.lineTo((_m, SC.ASCENT - _m))
pen.closePath()
glyph_table[".notdef"] = pen.glyph()
traced_count = 0
for cp in sorted_cps:
g = glyphs[cp]
if g.props.is_illegal:
continue
name = glyph_name(cp)
if name == ".notdef" or name not in glyph_set:
continue
contours = trace_bitmap(g.bitmap, g.props.width)
pen = TTGlyphPen(None)
if contours:
draw_glyph_to_pen(contours, pen)
glyph_table[name] = pen.glyph()
traced_count += 1
else:
# Empty glyph (space, zero-width, etc.)
pen.moveTo((0, 0))
pen.endPath()
glyph_table[name] = pen.glyph()
print(f" Traced {traced_count} glyphs with outlines")
fb.setupGlyf(glyph_table)
# Step 6: Set metrics
print("Step 6: Setting font metrics...")
metrics = {}
metrics[".notdef"] = (SC.UNITS_PER_EM // 2, 0)
for cp in sorted_cps:
g = glyphs[cp]
if g.props.is_illegal:
continue
name = glyph_name(cp)
if name == ".notdef" or name not in glyph_set:
continue
advance = g.props.width * SCALE
metrics[name] = (advance, 0) # (advance_width, lsb)
fb.setupHorizontalMetrics(metrics)
fb.setupHorizontalHeader(
ascent=SC.ASCENT,
descent=-SC.DESCENT
)
fb.setupNameTable({
"familyName": "Terrarum Sans Bitmap",
"styleName": "Regular",
})
fb.setupOS2(
sTypoAscender=SC.ASCENT,
sTypoDescender=-SC.DESCENT,
sTypoLineGap=SC.LINE_GAP,
usWinAscent=SC.ASCENT,
usWinDescent=SC.DESCENT,
sxHeight=SC.X_HEIGHT,
sCapHeight=SC.CAP_HEIGHT,
fsType=0, # Installable embedding
)
fb.setupPost()
fb.setupHead(unitsPerEm=SC.UNITS_PER_EM)
font = fb.font
# Step 7: Generate and compile OpenType features
if not no_features:
print("Step 7: Generating OpenType features...")
kern_pairs = generate_kerning_pairs(glyphs)
print(f" {len(kern_pairs)} kerning pairs")
fea_code = generate_features(glyphs, kern_pairs, glyph_set)
if fea_code.strip():
print(" Compiling features with feaLib...")
try:
fea_stream = io.StringIO(fea_code)
addOpenTypeFeatures(font, fea_stream)
print(" Features compiled successfully")
except Exception as e:
print(f" [WARNING] Feature compilation failed: {e}")
print(" Continuing without OpenType features")
else:
print(" No features to compile")
else:
print("Step 7: Skipping OpenType features (--no-features)")
# Step 8: Add bitmap strike (EBDT/EBLC)
if not no_bitmap:
print("Step 8: Adding bitmap strike...")
_add_bitmap_strike(font, glyphs, glyph_order, glyph_set)
else:
print("Step 8: Skipping bitmap strike (--no-bitmap)")
# Save
print(f"Saving to {output_path}...")
font.save(output_path)
elapsed = time.time() - t0
print(f"Done! Built {len(glyph_order)} glyphs in {elapsed:.1f}s")
print(f"Output: {output_path}")
def _add_bitmap_strike(font, glyphs, glyph_order, glyph_set):
"""Add EBDT/EBLC embedded bitmap strike at ppem=20 via TTX roundtrip."""
import tempfile
import os as _os
ppem = 20
name_to_id = {name: idx for idx, name in enumerate(glyph_order)}
# Collect bitmap data — only glyphs with actual pixels
bitmap_entries = []
for name in glyph_order:
if name == ".notdef":
continue
cp = _name_to_cp(name)
if cp is None or cp not in glyphs:
continue
g = glyphs[cp]
if g.props.is_illegal or g.props.width == 0:
continue
bitmap = g.bitmap
h = len(bitmap)
w = len(bitmap[0]) if h > 0 else 0
if w == 0 or h == 0:
continue
# Pack rows into hex
hex_rows = []
for row in bitmap:
row_bytes = bytearray()
for col_start in range(0, w, 8):
byte_val = 0
for bit in range(8):
col = col_start + bit
if col < w and row[col]:
byte_val |= (0x80 >> bit)
row_bytes.append(byte_val)
hex_rows.append(row_bytes.hex())
bitmap_entries.append({
'name': name,
'gid': name_to_id.get(name, 0),
'height': h,
'width': w,
'advance': g.props.width,
'hex_rows': hex_rows,
})
if not bitmap_entries:
print(" No bitmap data to embed")
return
# Split into contiguous GID runs for separate index subtables
# This avoids the empty-name problem for gaps
gid_sorted = sorted(bitmap_entries, key=lambda e: e['gid'])
gid_to_entry = {e['gid']: e for e in gid_sorted}
runs = [] # list of lists of entries
current_run = [gid_sorted[0]]
for i in range(1, len(gid_sorted)):
if gid_sorted[i]['gid'] == gid_sorted[i-1]['gid'] + 1:
current_run.append(gid_sorted[i])
else:
runs.append(current_run)
current_run = [gid_sorted[i]]
runs.append(current_run)
# Build TTX XML for EBDT
ebdt_xml = ['<EBDT>', '<header version="2.0"/>', '<strikedata index="0">']
for entry in gid_sorted:
ebdt_xml.append(f' <cbdt_bitmap_format_1 name="{entry["name"]}">')
ebdt_xml.append(f' <SmallGlyphMetrics>')
ebdt_xml.append(f' <height value="{entry["height"]}"/>')
ebdt_xml.append(f' <width value="{entry["width"]}"/>')
ebdt_xml.append(f' <BearingX value="0"/>')
ebdt_xml.append(f' <BearingY value="{BASELINE_ROW}"/>')
ebdt_xml.append(f' <Advance value="{entry["advance"]}"/>')
ebdt_xml.append(f' </SmallGlyphMetrics>')
ebdt_xml.append(f' <rawimagedata>')
for hr in entry['hex_rows']:
ebdt_xml.append(f' {hr}')
ebdt_xml.append(f' </rawimagedata>')
ebdt_xml.append(f' </cbdt_bitmap_format_1>')
ebdt_xml.append('</strikedata>')
ebdt_xml.append('</EBDT>')
# Build TTX XML for EBLC
all_gids = [e['gid'] for e in gid_sorted]
desc = -(SC.H - BASELINE_ROW)
def _line_metrics_xml(direction, caret_num=1):
return [
f' <sbitLineMetrics direction="{direction}">',
f' <ascender value="{BASELINE_ROW}"/>',
f' <descender value="{desc}"/>',
f' <widthMax value="{SC.W_WIDEVAR_INIT}"/>',
f' <caretSlopeNumerator value="{caret_num}"/>',
' <caretSlopeDenominator value="0"/>',
' <caretOffset value="0"/>',
' <minOriginSB value="0"/>',
' <minAdvanceSB value="0"/>',
f' <maxBeforeBL value="{BASELINE_ROW}"/>',
f' <minAfterBL value="{desc}"/>',
' <pad1 value="0"/>',
' <pad2 value="0"/>',
f' </sbitLineMetrics>',
]
eblc_xml = [
'<EBLC>', '<header version="2.0"/>',
'<strike index="0">', ' <bitmapSizeTable>',
' <colorRef value="0"/>',
]
eblc_xml.extend(_line_metrics_xml("hori", 1))
eblc_xml.extend(_line_metrics_xml("vert", 0))
eblc_xml.extend([
f' <startGlyphIndex value="{all_gids[0]}"/>',
f' <endGlyphIndex value="{all_gids[-1]}"/>',
f' <ppemX value="{ppem}"/>',
f' <ppemY value="{ppem}"/>',
' <bitDepth value="1"/>',
' <flags value="1"/>',
' </bitmapSizeTable>',
])
# One index subtable per contiguous run — no gaps
# Use format 1 (32-bit offsets) to avoid 16-bit overflow
for run in runs:
first_gid = run[0]['gid']
last_gid = run[-1]['gid']
eblc_xml.append(f' <eblc_index_sub_table_1 imageFormat="1" firstGlyphIndex="{first_gid}" lastGlyphIndex="{last_gid}">')
for entry in run:
eblc_xml.append(f' <glyphLoc name="{entry["name"]}"/>')
eblc_xml.append(' </eblc_index_sub_table_1>')
eblc_xml.append('</strike>')
eblc_xml.append('</EBLC>')
try:
ttx_content = '<?xml version="1.0" encoding="UTF-8"?>\n<ttFont>\n'
ttx_content += '\n'.join(ebdt_xml) + '\n'
ttx_content += '\n'.join(eblc_xml) + '\n'
ttx_content += '</ttFont>\n'
with tempfile.NamedTemporaryFile(mode='w', suffix='.ttx', delete=False) as f:
f.write(ttx_content)
ttx_path = f.name
font.importXML(ttx_path)
_os.unlink(ttx_path)
print(f" Added bitmap strike at {ppem}ppem with {len(bitmap_entries)} glyphs ({len(runs)} index subtables)")
except Exception as e:
print(f" [WARNING] Bitmap strike failed: {e}")
print(" Continuing without bitmap strike")
def _name_to_cp(name):
"""Convert glyph name back to codepoint."""
if name == ".notdef":
return None
if name == "space":
return 0x20
if name.startswith("uni"):
try:
return int(name[3:], 16)
except ValueError:
return None
if name.startswith("u"):
try:
return int(name[1:], 16)
except ValueError:
return None
return None

328
OTFbuild/glyph_parser.py Normal file
View File

@@ -0,0 +1,328 @@
"""
Extract glyph bitmaps and tag-column properties from TGA sprite sheets.
Ported from TerrarumSansBitmap.kt:buildWidthTable() and GlyphSheetParser.kt.
Enhancement over v1: extracts all 6 diacritics anchors for GPOS mark feature.
"""
import os
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from tga_reader import TgaImage, read_tga
import sheet_config as SC
@dataclass
class DiacriticsAnchor:
type: int
x: int
y: int
x_used: bool
y_used: bool
@dataclass
class GlyphProps:
width: int
is_low_height: bool = False
nudge_x: int = 0
nudge_y: int = 0
diacritics_anchors: List[DiacriticsAnchor] = field(default_factory=lambda: [
DiacriticsAnchor(i, 0, 0, False, False) for i in range(6)
])
align_where: int = 0
write_on_top: int = -1
stack_where: int = 0
ext_info: List[int] = field(default_factory=lambda: [0] * 15)
has_kern_data: bool = False
is_kern_y_type: bool = False
kerning_mask: int = 255
directive_opcode: int = 0
directive_arg1: int = 0
directive_arg2: int = 0
@property
def is_illegal(self):
return self.directive_opcode == 255
def required_ext_info_count(self):
if self.stack_where == SC.STACK_BEFORE_N_AFTER:
return 2
if 0b10000_000 <= self.directive_opcode <= 0b10000_111:
return 7
return 0
def is_pragma(self, pragma):
if pragma == "replacewith":
return 0b10000_000 <= self.directive_opcode <= 0b10000_111
return False
@dataclass
class ExtractedGlyph:
codepoint: int
props: GlyphProps
bitmap: List[List[int]] # [row][col], 0 or 1
def _tagify(pixel):
"""Return 0 if alpha channel is zero, else return the original value."""
return 0 if (pixel & 0xFF) == 0 else pixel
def _signed_byte(val):
"""Convert unsigned byte to signed."""
return val - 256 if val >= 128 else val
def _parse_diacritics_anchors(image, code_start_x, code_start_y):
"""Parse 6 diacritics anchors from tag column rows 11-14."""
anchors = []
for i in range(6):
y_pos = 13 - (i // 3) * 2
shift = (3 - (i % 3)) * 8
y_pixel = _tagify(image.get_pixel(code_start_x, code_start_y + y_pos))
x_pixel = _tagify(image.get_pixel(code_start_x, code_start_y + y_pos + 1))
y_used = ((y_pixel >> shift) & 128) != 0
x_used = ((x_pixel >> shift) & 128) != 0
y_val = (y_pixel >> shift) & 127 if y_used else 0
x_val = (x_pixel >> shift) & 127 if x_used else 0
anchors.append(DiacriticsAnchor(i, x_val, y_val, x_used, y_used))
return anchors
def parse_variable_sheet(image, sheet_index, cell_w, cell_h, cols, is_xy_swapped):
"""Parse a variable-width sheet: extract tag column for properties, bitmap for glyph."""
code_range = SC.CODE_RANGE[sheet_index]
binary_code_offset = cell_w - 1 # tag column is last pixel column of cell
result = {}
for index, code in enumerate(code_range):
if is_xy_swapped:
cell_x = (index // cols) * cell_w
cell_y = (index % cols) * cell_h
else:
cell_x = (index % cols) * cell_w
cell_y = (index // cols) * cell_h
code_start_x = cell_x + binary_code_offset
code_start_y = cell_y
# Width (5 bits)
width = 0
for y in range(5):
if image.get_pixel(code_start_x, code_start_y + y) & 0xFF:
width |= (1 << y)
is_low_height = (image.get_pixel(code_start_x, code_start_y + 5) & 0xFF) != 0
# Kerning data
kerning_bit1 = _tagify(image.get_pixel(code_start_x, code_start_y + 6))
# kerning_bit2 and kerning_bit3 are reserved
is_kern_y_type = (kerning_bit1 & 0x80000000) != 0
kerning_mask = (kerning_bit1 >> 8) & 0xFFFFFF
has_kern_data = (kerning_bit1 & 0xFF) != 0
if not has_kern_data:
is_kern_y_type = False
kerning_mask = 255
# Compiler directives
compiler_directives = _tagify(image.get_pixel(code_start_x, code_start_y + 9))
directive_opcode = (compiler_directives >> 24) & 255
directive_arg1 = (compiler_directives >> 16) & 255
directive_arg2 = (compiler_directives >> 8) & 255
# Nudge
nudging_bits = _tagify(image.get_pixel(code_start_x, code_start_y + 10))
nudge_x = _signed_byte((nudging_bits >> 24) & 0xFF)
nudge_y = _signed_byte((nudging_bits >> 16) & 0xFF)
# Diacritics anchors
diacritics_anchors = _parse_diacritics_anchors(image, code_start_x, code_start_y)
# Alignment
align_where = 0
for y in range(2):
if image.get_pixel(code_start_x, code_start_y + y + 15) & 0xFF:
align_where |= (1 << y)
# Write on top
write_on_top_raw = image.get_pixel(code_start_x, code_start_y + 17) # NO tagify
if (write_on_top_raw & 0xFF) == 0:
write_on_top = -1
else:
if (write_on_top_raw >> 8) == 0xFFFFFF:
write_on_top = 0
else:
write_on_top = (write_on_top_raw >> 28) & 15
# Stack where
stack_where0 = _tagify(image.get_pixel(code_start_x, code_start_y + 18))
stack_where1 = _tagify(image.get_pixel(code_start_x, code_start_y + 19))
if stack_where0 == 0x00FF00FF and stack_where1 == 0x00FF00FF:
stack_where = SC.STACK_DONT
else:
stack_where = 0
for y in range(2):
if image.get_pixel(code_start_x, code_start_y + y + 18) & 0xFF:
stack_where |= (1 << y)
ext_info = [0] * 15
props = GlyphProps(
width=width, is_low_height=is_low_height,
nudge_x=nudge_x, nudge_y=nudge_y,
diacritics_anchors=diacritics_anchors,
align_where=align_where, write_on_top=write_on_top,
stack_where=stack_where, ext_info=ext_info,
has_kern_data=has_kern_data, is_kern_y_type=is_kern_y_type,
kerning_mask=kerning_mask,
directive_opcode=directive_opcode, directive_arg1=directive_arg1,
directive_arg2=directive_arg2,
)
# Parse extInfo if needed
ext_count = props.required_ext_info_count()
if ext_count > 0:
for x in range(ext_count):
info = 0
for y in range(20):
if image.get_pixel(cell_x + x, cell_y + y) & 0xFF:
info |= (1 << y)
ext_info[x] = info
# Extract glyph bitmap (all pixels except tag column)
bitmap_w = cell_w - 1
bitmap = []
for row in range(cell_h):
row_data = []
for col in range(bitmap_w):
px = image.get_pixel(cell_x + col, cell_y + row)
row_data.append(1 if (px & 0xFF) != 0 else 0)
bitmap.append(row_data)
result[code] = ExtractedGlyph(code, props, bitmap)
return result
def parse_fixed_sheet(image, sheet_index, cell_w, cell_h, cols):
"""Parse a fixed-width sheet (Hangul, Unihan, Runic, Custom Sym)."""
code_range = SC.CODE_RANGE[sheet_index]
result = {}
fixed_width = {
SC.SHEET_CUSTOM_SYM: 20,
SC.SHEET_HANGUL: SC.W_HANGUL_BASE,
SC.SHEET_RUNIC: 9,
SC.SHEET_UNIHAN: SC.W_UNIHAN,
}.get(sheet_index, cell_w)
for index, code in enumerate(code_range):
cell_x = (index % cols) * cell_w
cell_y = (index // cols) * cell_h
bitmap = []
for row in range(cell_h):
row_data = []
for col in range(cell_w):
px = image.get_pixel(cell_x + col, cell_y + row)
row_data.append(1 if (px & 0xFF) != 0 else 0)
bitmap.append(row_data)
props = GlyphProps(width=fixed_width)
result[code] = ExtractedGlyph(code, props, bitmap)
return result
def _empty_bitmap(w=SC.W_VAR_INIT, h=SC.H):
return [[0] * w for _ in range(h)]
def parse_all_sheets(assets_dir):
"""Parse all sheets and return a map of codepoint -> ExtractedGlyph."""
result = {}
for sheet_index, filename in enumerate(SC.FILE_LIST):
filepath = os.path.join(assets_dir, filename)
if not os.path.exists(filepath):
print(f" [SKIP] {filename} not found")
continue
is_var = SC.is_variable(filename)
is_xy = SC.is_xy_swapped(filename)
is_ew = SC.is_extra_wide(filename)
cell_w = SC.get_cell_width(sheet_index)
cell_h = SC.get_cell_height(sheet_index)
cols = SC.get_columns(sheet_index)
tags = []
if is_var: tags.append("VARIABLE")
if is_xy: tags.append("XYSWAP")
if is_ew: tags.append("EXTRAWIDE")
if not tags: tags.append("STATIC")
print(f" Loading [{','.join(tags)}] {filename}")
image = read_tga(filepath)
if is_var:
sheet_glyphs = parse_variable_sheet(image, sheet_index, cell_w, cell_h, cols, is_xy)
else:
sheet_glyphs = parse_fixed_sheet(image, sheet_index, cell_w, cell_h, cols)
result.update(sheet_glyphs)
# Fixed-width overrides
_add_fixed_width_overrides(result)
return result
def _add_fixed_width_overrides(result):
"""Apply fixed-width overrides."""
# Hangul compat jamo
for code in SC.CODE_RANGE_HANGUL_COMPAT:
if code not in result:
result[code] = ExtractedGlyph(code, GlyphProps(width=SC.W_HANGUL_BASE), _empty_bitmap(SC.W_HANGUL_BASE))
# Zero-width ranges (only internal/PUA control ranges, not surrogates or full Plane 16)
for code in range(0xFFFA0, 0x100000):
result[code] = ExtractedGlyph(code, GlyphProps(width=0), _empty_bitmap(1, 1))
# Null char
result[0] = ExtractedGlyph(0, GlyphProps(width=0), _empty_bitmap(1, 1))
# Replacement character at U+007F
if 0x7F in result:
result[0x7F].props.width = 15
def get_hangul_jamo_bitmaps(assets_dir):
"""
Extract raw Hangul jamo bitmaps from the Hangul sheet for composition.
Returns a function: (index, row) -> bitmap (list of list of int)
"""
filename = SC.FILE_LIST[SC.SHEET_HANGUL]
filepath = os.path.join(assets_dir, filename)
if not os.path.exists(filepath):
print(" [WARNING] Hangul sheet not found")
return lambda idx, row: _empty_bitmap(SC.W_HANGUL_BASE)
image = read_tga(filepath)
cell_w = SC.W_HANGUL_BASE
cell_h = SC.H
def get_bitmap(index, row):
cell_x = index * cell_w
cell_y = row * cell_h
bitmap = []
for r in range(cell_h):
row_data = []
for c in range(cell_w):
px = image.get_pixel(cell_x + c, cell_y + r)
row_data.append(1 if (px & 0xFF) != 0 else 0)
bitmap.append(row_data)
return bitmap
return get_bitmap

98
OTFbuild/hangul.py Normal file
View File

@@ -0,0 +1,98 @@
"""
Compose 11,172 Hangul syllables (U+AC00-U+D7A3) from jamo sprite pieces.
Also composes Hangul Compatibility Jamo (U+3130-U+318F).
Ported from HangulCompositor.kt and TerrarumSansBitmap.kt.
"""
from typing import Dict, List, Tuple
from glyph_parser import ExtractedGlyph, GlyphProps, get_hangul_jamo_bitmaps
import sheet_config as SC
def _compose_bitmaps(a, b, w, h):
"""OR two bitmaps together."""
result = []
for row in range(h):
row_data = []
for col in range(w):
av = a[row][col] if row < len(a) and col < len(a[row]) else 0
bv = b[row][col] if row < len(b) and col < len(b[row]) else 0
row_data.append(1 if av or bv else 0)
result.append(row_data)
return result
def _compose_bitmap_into(target, source, w, h):
"""OR source bitmap into target (mutates target)."""
for row in range(min(h, len(target), len(source))):
for col in range(min(w, len(target[row]), len(source[row]))):
if source[row][col]:
target[row][col] = 1
def compose_hangul(assets_dir) -> Dict[int, ExtractedGlyph]:
"""
Compose all Hangul syllables and compatibility jamo.
Returns a dict of codepoint -> ExtractedGlyph.
"""
get_jamo = get_hangul_jamo_bitmaps(assets_dir)
cell_w = SC.W_HANGUL_BASE
cell_h = SC.H
result = {}
# Compose Hangul Compatibility Jamo (U+3130-U+318F)
for c in range(0x3130, 0x3190):
index = c - 0x3130
bitmap = get_jamo(index, 0)
props = GlyphProps(width=cell_w)
result[c] = ExtractedGlyph(c, props, bitmap)
# Compose 11,172 Hangul syllables (U+AC00-U+D7A3)
print(" Composing 11,172 Hangul syllables...")
for c in range(0xAC00, 0xD7A4):
c_int = c - 0xAC00
index_cho = c_int // (SC.JUNG_COUNT * SC.JONG_COUNT)
index_jung = c_int // SC.JONG_COUNT % SC.JUNG_COUNT
index_jong = c_int % SC.JONG_COUNT # 0 = no jongseong
# Map to jamo codepoints
cho_cp = 0x1100 + index_cho
jung_cp = 0x1161 + index_jung
jong_cp = 0x11A8 + index_jong - 1 if index_jong > 0 else 0
# Get sheet indices
i_cho = SC.to_hangul_choseong_index(cho_cp)
i_jung = SC.to_hangul_jungseong_index(jung_cp)
if i_jung is None:
i_jung = 0
i_jong = 0
if jong_cp != 0:
idx = SC.to_hangul_jongseong_index(jong_cp)
if idx is not None:
i_jong = idx
# Get row positions
cho_row = SC.get_han_initial_row(i_cho, i_jung, i_jong)
jung_row = SC.get_han_medial_row(i_cho, i_jung, i_jong)
jong_row = SC.get_han_final_row(i_cho, i_jung, i_jong)
# Get jamo bitmaps
cho_bitmap = get_jamo(i_cho, cho_row)
jung_bitmap = get_jamo(i_jung, jung_row)
# Compose
composed = _compose_bitmaps(cho_bitmap, jung_bitmap, cell_w, cell_h)
if index_jong > 0:
jong_bitmap = get_jamo(i_jong, jong_row)
_compose_bitmap_into(composed, jong_bitmap, cell_w, cell_h)
# Determine advance width
advance_width = cell_w + 1 if i_jung in SC.HANGUL_PEAKS_WITH_EXTRA_WIDTH else cell_w
props = GlyphProps(width=advance_width)
result[c] = ExtractedGlyph(c, props, composed)
print(f" Hangul composition done: {len(result)} glyphs")
return result

126
OTFbuild/keming_machine.py Normal file
View File

@@ -0,0 +1,126 @@
"""
Generate kerning pairs from shape rules.
Ported from TerrarumSansBitmap.kt "The Keming Machine" section.
6 base rules + 6 mirrored (auto-generated) = 12 rules total.
Also includes r+dot special pairs.
Output kern values scaled by SCALE (50 units/pixel):
-1px -> -50 units, -2px -> -100 units
"""
from typing import Dict, Tuple
from glyph_parser import ExtractedGlyph
import sheet_config as SC
SCALE = SC.SCALE
class _Ing:
"""Pattern matcher for kerning shape bits."""
def __init__(self, s):
self.s = s
self.care_bits = 0
self.rule_bits = 0
for index, char in enumerate(s):
if char == '@':
self.care_bits |= SC.KEMING_BIT_MASK[index]
self.rule_bits |= SC.KEMING_BIT_MASK[index]
elif char == '`':
self.care_bits |= SC.KEMING_BIT_MASK[index]
def matches(self, shape_bits):
return (shape_bits & self.care_bits) == self.rule_bits
class _Kem:
def __init__(self, first, second, bb=2, yy=1):
self.first = first
self.second = second
self.bb = bb
self.yy = yy
def _build_kerning_rules():
"""Build the 12 kerning rules (6 base + 6 mirrored)."""
base_rules = [
_Kem(_Ing("_`_@___`__"), _Ing("`_`___@___")),
_Kem(_Ing("_@_`___`__"), _Ing("`_________")),
_Kem(_Ing("_@_@___`__"), _Ing("`___@_@___"), 1, 1),
_Kem(_Ing("_@_@_`_`__"), _Ing("`_____@___")),
_Kem(_Ing("___`_`____"), _Ing("`___@_`___")),
_Kem(_Ing("___`_`____"), _Ing("`_@___`___")),
]
mirrored = []
for rule in base_rules:
left = rule.first.s
right = rule.second.s
new_left = []
new_right = []
for c in range(0, len(left), 2):
new_left.append(right[c + 1])
new_left.append(right[c])
new_right.append(left[c + 1])
new_right.append(left[c])
mirrored.append(_Kem(
_Ing(''.join(new_left)),
_Ing(''.join(new_right)),
rule.bb, rule.yy
))
return base_rules + mirrored
_KERNING_RULES = _build_kerning_rules()
def generate_kerning_pairs(glyphs: Dict[int, ExtractedGlyph]) -> Dict[Tuple[int, int], int]:
"""
Generate kerning pairs from all glyphs that have kerning data.
Returns dict of (left_codepoint, right_codepoint) -> kern_offset_in_font_units.
Negative values = tighter spacing.
"""
result = {}
# Collect all codepoints with kerning data
kernable = {cp: g for cp, g in glyphs.items() if g.props.has_kern_data}
if not kernable:
print(" [KemingMachine] No glyphs with kern data found")
return result
print(f" [KemingMachine] {len(kernable)} glyphs with kern data")
# Special rule: lowercase r + dot
r_dot_count = 0
for r in SC.LOWERCASE_RS:
for d in SC.DOTS:
if r in glyphs and d in glyphs:
result[(r, d)] = -1 * SCALE
r_dot_count += 1
# Apply kerning rules to all pairs
kern_codes = list(kernable.keys())
pairs_found = 0
for left_code in kern_codes:
left_props = kernable[left_code].props
mask_l = left_props.kerning_mask
for right_code in kern_codes:
right_props = kernable[right_code].props
mask_r = right_props.kerning_mask
for rule in _KERNING_RULES:
if rule.first.matches(mask_l) and rule.second.matches(mask_r):
contraction = rule.yy if (left_props.is_kern_y_type or right_props.is_kern_y_type) else rule.bb
if contraction > 0:
result[(left_code, right_code)] = -contraction * SCALE
pairs_found += 1
break # first matching rule wins
print(f" [KemingMachine] Generated {pairs_found} kerning pairs (+ {r_dot_count} r-dot pairs)")
return result

View File

@@ -0,0 +1,449 @@
"""
Generate OpenType feature code (feaLib syntax) for GSUB/GPOS tables.
Features implemented:
- kern: GPOS pair positioning from KemingMachine
- liga: Standard ligatures (Alphabetic Presentation Forms)
- locl: Bulgarian/Serbian Cyrillic variants
- Devanagari GSUB: nukt, akhn, half, vatu, pres, blws, rphf
- Tamil GSUB: consonant+vowel ligatures, KSSA, SHRII
- Sundanese GSUB: diacritic combinations
- mark: GPOS mark-to-base positioning (diacritics anchors)
"""
from typing import Dict, List, Set, Tuple
from glyph_parser import ExtractedGlyph
import sheet_config as SC
def glyph_name(cp):
"""Generate standard glyph name for a codepoint."""
if cp == 0:
return ".notdef"
if cp == 0x20:
return "space"
if cp <= 0xFFFF:
return f"uni{cp:04X}"
return f"u{cp:05X}" if cp <= 0xFFFFF else f"u{cp:06X}"
def generate_features(glyphs, kern_pairs, font_glyph_set):
"""
Generate complete OpenType feature code string.
Args:
glyphs: dict of codepoint -> ExtractedGlyph
kern_pairs: dict of (left_cp, right_cp) -> kern_value_in_font_units
font_glyph_set: set of glyph names actually present in the font
Returns:
Feature code string for feaLib compilation.
"""
parts = []
def has(cp):
return glyph_name(cp) in font_glyph_set
# kern feature
kern_code = _generate_kern(kern_pairs, has)
if kern_code:
parts.append(kern_code)
# liga feature
liga_code = _generate_liga(has)
if liga_code:
parts.append(liga_code)
# locl feature (Bulgarian/Serbian)
locl_code = _generate_locl(glyphs, has)
if locl_code:
parts.append(locl_code)
# Devanagari features
deva_code = _generate_devanagari(glyphs, has)
if deva_code:
parts.append(deva_code)
# Tamil features
tamil_code = _generate_tamil(glyphs, has)
if tamil_code:
parts.append(tamil_code)
# Sundanese features
sund_code = _generate_sundanese(glyphs, has)
if sund_code:
parts.append(sund_code)
# mark feature
mark_code = _generate_mark(glyphs, has)
if mark_code:
parts.append(mark_code)
return '\n\n'.join(parts)
def _generate_kern(kern_pairs, has):
"""Generate kern feature from pair positioning data."""
if not kern_pairs:
return ""
lines = ["feature kern {"]
count = 0
for (left_cp, right_cp), value in sorted(kern_pairs.items()):
if has(left_cp) and has(right_cp):
lines.append(f" pos {glyph_name(left_cp)} {glyph_name(right_cp)} {value};")
count += 1
if count == 0:
return ""
lines.append("} kern;")
return '\n'.join(lines)
def _generate_liga(has):
"""Generate liga feature for Alphabetic Presentation Forms."""
subs = []
_liga_rules = [
([0x66, 0x66, 0x69], 0xFB03, "ffi"),
([0x66, 0x66, 0x6C], 0xFB04, "ffl"),
([0x66, 0x66], 0xFB00, "ff"),
([0x66, 0x69], 0xFB01, "fi"),
([0x66, 0x6C], 0xFB02, "fl"),
([0x17F, 0x74], 0xFB05, "long-s t"),
([0x73, 0x74], 0xFB06, "st"),
]
for seq, result_cp, name in _liga_rules:
if all(has(c) for c in seq) and has(result_cp):
seq_names = ' '.join(glyph_name(c) for c in seq)
subs.append(f" sub {seq_names} by {glyph_name(result_cp)}; # {name}")
_armenian_rules = [
([0x574, 0x576], 0xFB13, "men now"),
([0x574, 0x565], 0xFB14, "men ech"),
([0x574, 0x56B], 0xFB15, "men ini"),
([0x57E, 0x576], 0xFB16, "vew now"),
([0x574, 0x56D], 0xFB17, "men xeh"),
]
for seq, result_cp, name in _armenian_rules:
if all(has(c) for c in seq) and has(result_cp):
seq_names = ' '.join(glyph_name(c) for c in seq)
subs.append(f" sub {seq_names} by {glyph_name(result_cp)}; # Armenian {name}")
if not subs:
return ""
lines = ["feature liga {"]
lines.extend(subs)
lines.append("} liga;")
return '\n'.join(lines)
def _generate_locl(glyphs, has):
"""Generate locl feature for Bulgarian and Serbian Cyrillic variants."""
bg_subs = []
sr_subs = []
for pua in range(0xF0000, 0xF0060):
cyrillic = pua - 0xF0000 + 0x0400
if has(pua) and has(cyrillic):
pua_bm = glyphs[pua].bitmap
cyr_bm = glyphs[cyrillic].bitmap
if pua_bm != cyr_bm:
bg_subs.append(f" sub {glyph_name(cyrillic)} by {glyph_name(pua)};")
for pua in range(0xF0060, 0xF00C0):
cyrillic = pua - 0xF0060 + 0x0400
if has(pua) and has(cyrillic):
pua_bm = glyphs[pua].bitmap
cyr_bm = glyphs[cyrillic].bitmap
if pua_bm != cyr_bm:
sr_subs.append(f" sub {glyph_name(cyrillic)} by {glyph_name(pua)};")
if not bg_subs and not sr_subs:
return ""
lines = ["feature locl {"]
lines.append(" script cyrl;")
if bg_subs:
lines.append(" language BGR;")
lines.append(" lookup BulgarianForms {")
lines.extend(bg_subs)
lines.append(" } BulgarianForms;")
if sr_subs:
lines.append(" language SRB;")
lines.append(" lookup SerbianForms {")
lines.extend(sr_subs)
lines.append(" } SerbianForms;")
lines.append("} locl;")
return '\n'.join(lines)
def _generate_devanagari(glyphs, has):
"""Generate Devanagari GSUB features: nukt, akhn, half, vatu, pres, blws, rphf."""
features = []
# --- nukt: consonant + nukta -> nukta form ---
nukt_subs = []
for uni_cp in range(0x0915, 0x093A):
internal = SC.to_deva_internal(uni_cp)
nukta_form = internal + 48
if has(uni_cp) and has(0x093C) and has(nukta_form):
nukt_subs.append(
f" sub {glyph_name(uni_cp)} {glyph_name(0x093C)} by {glyph_name(nukta_form)};"
)
if nukt_subs:
features.append("feature nukt {\n script dev2;\n" + '\n'.join(nukt_subs) + "\n} nukt;")
# --- akhn: akhand ligatures ---
akhn_subs = []
if has(0x0915) and has(SC.DEVANAGARI_VIRAMA) and has(0x0937) and has(SC.DEVANAGARI_LIG_K_SS):
akhn_subs.append(
f" sub {glyph_name(0x0915)} {glyph_name(SC.DEVANAGARI_VIRAMA)} {glyph_name(0x0937)} by {glyph_name(SC.DEVANAGARI_LIG_K_SS)};"
)
if has(0x091C) and has(SC.DEVANAGARI_VIRAMA) and has(0x091E) and has(SC.DEVANAGARI_LIG_J_NY):
akhn_subs.append(
f" sub {glyph_name(0x091C)} {glyph_name(SC.DEVANAGARI_VIRAMA)} {glyph_name(0x091E)} by {glyph_name(SC.DEVANAGARI_LIG_J_NY)};"
)
if akhn_subs:
features.append("feature akhn {\n script dev2;\n" + '\n'.join(akhn_subs) + "\n} akhn;")
# --- half: consonant + virama -> half form ---
half_subs = []
for uni_cp in range(0x0915, 0x093A):
internal = SC.to_deva_internal(uni_cp)
half_form = internal + 240
if has(uni_cp) and has(SC.DEVANAGARI_VIRAMA) and has(half_form):
half_subs.append(
f" sub {glyph_name(uni_cp)} {glyph_name(SC.DEVANAGARI_VIRAMA)} by {glyph_name(half_form)};"
)
if half_subs:
features.append("feature half {\n script dev2;\n" + '\n'.join(half_subs) + "\n} half;")
# --- vatu: consonant + virama + RA -> RA-appended form ---
vatu_subs = []
for uni_cp in range(0x0915, 0x093A):
internal = SC.to_deva_internal(uni_cp)
ra_form = internal + 480
if has(uni_cp) and has(SC.DEVANAGARI_VIRAMA) and has(0x0930) and has(ra_form):
vatu_subs.append(
f" sub {glyph_name(uni_cp)} {glyph_name(SC.DEVANAGARI_VIRAMA)} {glyph_name(0x0930)} by {glyph_name(ra_form)};"
)
if vatu_subs:
features.append("feature vatu {\n script dev2;\n" + '\n'.join(vatu_subs) + "\n} vatu;")
# --- pres: named conjunct ligatures ---
pres_subs = []
_conjuncts = [
(0x0915, 0x0924, SC.DEVANAGARI_LIG_K_T, "K.T"),
(0x0924, 0x0924, SC.DEVANAGARI_LIG_T_T, "T.T"),
(0x0928, 0x0924, SC.DEVANAGARI_LIG_N_T, "N.T"),
(0x0928, 0x0928, SC.DEVANAGARI_LIG_N_N, "N.N"),
(0x0926, 0x0917, 0xF01B0, "D.G"),
(0x0926, 0x0918, 0xF01B1, "D.GH"),
(0x0926, 0x0926, 0xF01B2, "D.D"),
(0x0926, 0x0927, 0xF01B3, "D.DH"),
(0x0926, 0x0928, 0xF01B4, "D.N"),
(0x0926, 0x092C, 0xF01B5, "D.B"),
(0x0926, 0x092D, 0xF01B6, "D.BH"),
(0x0926, 0x092E, 0xF01B7, "D.M"),
(0x0926, 0x092F, 0xF01B8, "D.Y"),
(0x0926, 0x0935, 0xF01B9, "D.V"),
(0x0938, 0x0935, SC.DEVANAGARI_LIG_S_V, "S.V"),
(0x0937, 0x092A, SC.DEVANAGARI_LIG_SS_P, "SS.P"),
(0x0936, 0x091A, SC.DEVANAGARI_LIG_SH_C, "SH.C"),
(0x0936, 0x0928, SC.DEVANAGARI_LIG_SH_N, "SH.N"),
(0x0936, 0x0935, SC.DEVANAGARI_LIG_SH_V, "SH.V"),
(0x0918, 0x091F, 0xF01BD, "GH.TT"),
(0x0918, 0x0920, 0xF01BE, "GH.TTH"),
(0x0918, 0x0922, 0xF01BF, "GH.DDH"),
(0x091F, 0x091F, 0xF01D6, "TT.TT"),
(0x091F, 0x0920, 0xF01D7, "TT.TTH"),
(0x0920, 0x0920, 0xF01D9, "TTH.TTH"),
(0x0921, 0x0921, 0xF01DB, "DD.DD"),
(0x0921, 0x0922, 0xF01DC, "DD.DDH"),
(0x0922, 0x0922, 0xF01DE, "DDH.DDH"),
(0x092A, 0x091F, 0xF01C0, "P.TT"),
(0x092A, 0x0920, 0xF01C1, "P.TTH"),
(0x092A, 0x0922, 0xF01C2, "P.DDH"),
(0x0937, 0x091F, 0xF01C3, "SS.TT"),
(0x0937, 0x0920, 0xF01C4, "SS.TTH"),
(0x0937, 0x0922, 0xF01C5, "SS.DDH"),
(0x0939, 0x0923, 0xF01C6, "H.NN"),
(0x0939, 0x0928, 0xF01C7, "H.N"),
(0x0939, 0x092E, 0xF01C8, "H.M"),
(0x0939, 0x092F, 0xF01C9, "H.Y"),
(0x0939, 0x0932, 0xF01CA, "H.L"),
(0x0939, 0x0935, 0xF01CB, "H.V"),
]
for c1, c2, result, name in _conjuncts:
if has(c1) and has(SC.DEVANAGARI_VIRAMA) and has(c2) and has(result):
pres_subs.append(
f" sub {glyph_name(c1)} {glyph_name(SC.DEVANAGARI_VIRAMA)} {glyph_name(c2)} by {glyph_name(result)}; # {name}"
)
if pres_subs:
features.append("feature pres {\n script dev2;\n" + '\n'.join(pres_subs) + "\n} pres;")
# --- blws: RA/RRA/HA + U/UU -> special syllables ---
blws_subs = []
_blws_rules = [
(0x0930, SC.DEVANAGARI_U, SC.DEVANAGARI_SYLL_RU, "Ru"),
(0x0930, SC.DEVANAGARI_UU, SC.DEVANAGARI_SYLL_RUU, "Ruu"),
(0x0931, SC.DEVANAGARI_U, SC.DEVANAGARI_SYLL_RRU, "RRu"),
(0x0931, SC.DEVANAGARI_UU, SC.DEVANAGARI_SYLL_RRUU, "RRuu"),
(0x0939, SC.DEVANAGARI_U, SC.DEVANAGARI_SYLL_HU, "Hu"),
(0x0939, SC.DEVANAGARI_UU, SC.DEVANAGARI_SYLL_HUU, "Huu"),
]
for c1, c2, result, name in _blws_rules:
if has(c1) and has(c2) and has(result):
blws_subs.append(
f" sub {glyph_name(c1)} {glyph_name(c2)} by {glyph_name(result)}; # {name}"
)
if blws_subs:
features.append("feature blws {\n script dev2;\n" + '\n'.join(blws_subs) + "\n} blws;")
# --- rphf: RA + virama -> reph ---
if has(0x0930) and has(SC.DEVANAGARI_VIRAMA) and has(SC.DEVANAGARI_RA_SUPER):
rphf_code = (
f"feature rphf {{\n"
f" script dev2;\n"
f" sub {glyph_name(0x0930)} {glyph_name(SC.DEVANAGARI_VIRAMA)} by {glyph_name(SC.DEVANAGARI_RA_SUPER)};\n"
f"}} rphf;"
)
features.append(rphf_code)
if not features:
return ""
return '\n\n'.join(features)
def _generate_tamil(glyphs, has):
"""Generate Tamil GSUB features."""
subs = []
_tamil_i_rules = [
(0x0B99, 0xF00F0, "nga+i"),
(0x0BAA, 0xF00F1, "pa+i"),
(0x0BAF, 0xF00F2, "ya+i"),
(0x0BB2, 0xF00F3, "la+i"),
(0x0BB5, 0xF00F4, "va+i"),
(0x0BB8, 0xF00F5, "sa+i"),
]
for cons, result, name in _tamil_i_rules:
if has(cons) and has(SC.TAMIL_I) and has(result):
subs.append(f" sub {glyph_name(cons)} {glyph_name(SC.TAMIL_I)} by {glyph_name(result)}; # {name}")
if has(0x0B9F) and has(0x0BBF) and has(0xF00C0):
subs.append(f" sub {glyph_name(0x0B9F)} {glyph_name(0x0BBF)} by {glyph_name(0xF00C0)}; # tta+i")
if has(0x0B9F) and has(0x0BC0) and has(0xF00C1):
subs.append(f" sub {glyph_name(0x0B9F)} {glyph_name(0x0BC0)} by {glyph_name(0xF00C1)}; # tta+ii")
for idx, cons in enumerate(SC.TAMIL_LIGATING_CONSONANTS):
u_form = 0xF00C2 + idx
uu_form = 0xF00D4 + idx
if has(cons) and has(0x0BC1) and has(u_form):
subs.append(f" sub {glyph_name(cons)} {glyph_name(0x0BC1)} by {glyph_name(u_form)};")
if has(cons) and has(0x0BC2) and has(uu_form):
subs.append(f" sub {glyph_name(cons)} {glyph_name(0x0BC2)} by {glyph_name(uu_form)};")
if has(0x0B95) and has(0x0BCD) and has(0x0BB7) and has(SC.TAMIL_KSSA):
subs.append(f" sub {glyph_name(0x0B95)} {glyph_name(0x0BCD)} {glyph_name(0x0BB7)} by {glyph_name(SC.TAMIL_KSSA)}; # KSSA")
if has(0x0BB6) and has(0x0BCD) and has(0x0BB0) and has(0x0BC0) and has(SC.TAMIL_SHRII):
subs.append(f" sub {glyph_name(0x0BB6)} {glyph_name(0x0BCD)} {glyph_name(0x0BB0)} {glyph_name(0x0BC0)} by {glyph_name(SC.TAMIL_SHRII)}; # SHRII (sha)")
if has(0x0BB8) and has(0x0BCD) and has(0x0BB0) and has(0x0BC0) and has(SC.TAMIL_SHRII):
subs.append(f" sub {glyph_name(0x0BB8)} {glyph_name(0x0BCD)} {glyph_name(0x0BB0)} {glyph_name(0x0BC0)} by {glyph_name(SC.TAMIL_SHRII)}; # SHRII (sa)")
if not subs:
return ""
lines = ["feature pres {", " script tml2;"]
lines.extend(subs)
lines.append("} pres;")
return '\n'.join(lines)
def _generate_sundanese(glyphs, has):
"""Generate Sundanese GSUB feature for diacritic combinations."""
subs = []
_rules = [
(0x1BA4, 0x1B80, SC.SUNDANESE_ING, "panghulu+panyecek=ing"),
(0x1BA8, 0x1B80, SC.SUNDANESE_ENG, "pamepet+panyecek=eng"),
(0x1BA9, 0x1B80, SC.SUNDANESE_EUNG, "paneuleung+panyecek=eung"),
(0x1BA4, 0x1B81, SC.SUNDANESE_IR, "panghulu+panglayar=ir"),
(0x1BA8, 0x1B81, SC.SUNDANESE_ER, "pamepet+panglayar=er"),
(0x1BA9, 0x1B81, SC.SUNDANESE_EUR, "paneuleung+panglayar=eur"),
(0x1BA3, 0x1BA5, SC.SUNDANESE_LU, "panyuku+panglayar=lu"),
]
for c1, c2, result, name in _rules:
if has(c1) and has(c2) and has(result):
subs.append(f" sub {glyph_name(c1)} {glyph_name(c2)} by {glyph_name(result)}; # {name}")
if not subs:
return ""
lines = ["feature pres {", " script sund;"]
lines.extend(subs)
lines.append("} pres;")
return '\n'.join(lines)
def _generate_mark(glyphs, has):
"""
Generate GPOS mark-to-base positioning using diacritics anchors from tag column.
"""
bases_with_anchors = {}
marks = {}
for cp, g in glyphs.items():
if not has(cp):
continue
if g.props.write_on_top >= 0:
marks[cp] = g
elif any(a.x_used or a.y_used for a in g.props.diacritics_anchors):
bases_with_anchors[cp] = g
if not bases_with_anchors or not marks:
return ""
lines = []
# Group marks by writeOnTop type
mark_classes = {}
for cp, g in marks.items():
mark_type = g.props.write_on_top
if mark_type not in mark_classes:
mark_classes[mark_type] = []
mark_classes[mark_type].append((cp, g))
for mark_type, mark_list in sorted(mark_classes.items()):
class_name = f"@mark_type{mark_type}"
for cp, g in mark_list:
mark_x = (g.props.width * SC.SCALE) // 2
mark_y = SC.ASCENT
lines.append(
f"markClass {glyph_name(cp)} <anchor {mark_x} {mark_y}> {class_name};"
)
lines.append("")
lines.append("feature mark {")
for mark_type, mark_list in sorted(mark_classes.items()):
class_name = f"@mark_type{mark_type}"
lookup_name = f"mark_type{mark_type}"
lines.append(f" lookup {lookup_name} {{")
for cp, g in sorted(bases_with_anchors.items()):
anchor = g.props.diacritics_anchors[mark_type] if mark_type < 6 else None
if anchor and (anchor.x_used or anchor.y_used):
ax = anchor.x * SC.SCALE
ay = (SC.ASCENT // SC.SCALE - anchor.y) * SC.SCALE
lines.append(f" pos base {glyph_name(cp)} <anchor {ax} {ay}> mark {class_name};")
lines.append(f" }} {lookup_name};")
lines.append("} mark;")
return '\n'.join(lines)

View File

@@ -0,0 +1 @@
fonttools>=4.47.0

533
OTFbuild/sheet_config.py Normal file
View File

@@ -0,0 +1,533 @@
"""
Sheet definitions, code ranges, index functions, and font metric constants.
Ported from TerrarumSansBitmap.kt companion object and SheetConfig.kt.
"""
# Font metrics
H = 20
H_UNIHAN = 16
W_HANGUL_BASE = 13
W_UNIHAN = 16
W_LATIN_WIDE = 9
W_VAR_INIT = 15
W_WIDEVAR_INIT = 31
HGAP_VAR = 1
SIZE_CUSTOM_SYM = 20
H_DIACRITICS = 3
H_STACKUP_LOWERCASE_SHIFTDOWN = 4
H_OVERLAY_LOWERCASE_SHIFTDOWN = 2
LINE_HEIGHT = 24
# OTF metrics (1000 UPM, scale = 50 units/pixel)
UNITS_PER_EM = 1000
SCALE = 50 # units per pixel
ASCENT = 16 * SCALE # 800
DESCENT = 4 * SCALE # 200
X_HEIGHT = 8 * SCALE # 400
CAP_HEIGHT = 12 * SCALE # 600
LINE_GAP = (LINE_HEIGHT - H) * SCALE # 200
# Sheet indices
SHEET_ASCII_VARW = 0
SHEET_HANGUL = 1
SHEET_EXTA_VARW = 2
SHEET_EXTB_VARW = 3
SHEET_KANA = 4
SHEET_CJK_PUNCT = 5
SHEET_UNIHAN = 6
SHEET_CYRILIC_VARW = 7
SHEET_HALFWIDTH_FULLWIDTH_VARW = 8
SHEET_UNI_PUNCT_VARW = 9
SHEET_GREEK_VARW = 10
SHEET_THAI_VARW = 11
SHEET_HAYEREN_VARW = 12
SHEET_KARTULI_VARW = 13
SHEET_IPA_VARW = 14
SHEET_RUNIC = 15
SHEET_LATIN_EXT_ADD_VARW = 16
SHEET_CUSTOM_SYM = 17
SHEET_BULGARIAN_VARW = 18
SHEET_SERBIAN_VARW = 19
SHEET_TSALAGI_VARW = 20
SHEET_PHONETIC_EXT_VARW = 21
SHEET_DEVANAGARI_VARW = 22
SHEET_KARTULI_CAPS_VARW = 23
SHEET_DIACRITICAL_MARKS_VARW = 24
SHEET_GREEK_POLY_VARW = 25
SHEET_EXTC_VARW = 26
SHEET_EXTD_VARW = 27
SHEET_CURRENCIES_VARW = 28
SHEET_INTERNAL_VARW = 29
SHEET_LETTERLIKE_MATHS_VARW = 30
SHEET_ENCLOSED_ALPHNUM_SUPL_VARW = 31
SHEET_TAMIL_VARW = 32
SHEET_BENGALI_VARW = 33
SHEET_BRAILLE_VARW = 34
SHEET_SUNDANESE_VARW = 35
SHEET_DEVANAGARI2_INTERNAL_VARW = 36
SHEET_CODESTYLE_ASCII_VARW = 37
SHEET_ALPHABETIC_PRESENTATION_FORMS = 38
SHEET_HENTAIGANA_VARW = 39
SHEET_UNKNOWN = 254
FILE_LIST = [
"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",
]
CODE_RANGE = [
list(range(0x00, 0x100)), # 0: ASCII
list(range(0x1100, 0x1200)) + list(range(0xA960, 0xA980)) + list(range(0xD7B0, 0xD800)), # 1: Hangul Jamo
list(range(0x100, 0x180)), # 2: Latin Ext A
list(range(0x180, 0x250)), # 3: Latin Ext B
list(range(0x3040, 0x3100)) + list(range(0x31F0, 0x3200)), # 4: Kana
list(range(0x3000, 0x3040)), # 5: CJK Punct
list(range(0x3400, 0xA000)), # 6: Unihan
list(range(0x400, 0x530)), # 7: Cyrillic
list(range(0xFF00, 0x10000)), # 8: Halfwidth/Fullwidth
list(range(0x2000, 0x20A0)), # 9: Uni Punct
list(range(0x370, 0x3CF)), # 10: Greek
list(range(0xE00, 0xE60)), # 11: Thai
list(range(0x530, 0x590)), # 12: Armenian
list(range(0x10D0, 0x1100)), # 13: Georgian
list(range(0x250, 0x300)), # 14: IPA
list(range(0x16A0, 0x1700)), # 15: Runic
list(range(0x1E00, 0x1F00)), # 16: Latin Ext Additional
list(range(0xE000, 0xE100)), # 17: Custom Sym (PUA)
list(range(0xF0000, 0xF0060)), # 18: Bulgarian
list(range(0xF0060, 0xF00C0)), # 19: Serbian
list(range(0x13A0, 0x13F6)), # 20: Cherokee
list(range(0x1D00, 0x1DC0)), # 21: Phonetic Ext
list(range(0x900, 0x980)) + list(range(0xF0100, 0xF0500)), # 22: Devanagari
list(range(0x1C90, 0x1CC0)), # 23: Georgian Caps
list(range(0x300, 0x370)), # 24: Diacritical Marks
list(range(0x1F00, 0x2000)), # 25: Greek Polytonic
list(range(0x2C60, 0x2C80)), # 26: Latin Ext C
list(range(0xA720, 0xA800)), # 27: Latin Ext D
list(range(0x20A0, 0x20D0)), # 28: Currencies
list(range(0xFFE00, 0xFFFA0)), # 29: Internal
list(range(0x2100, 0x2150)), # 30: Letterlike
list(range(0x1F100, 0x1F200)), # 31: Enclosed Alphanum Supl
list(range(0x0B80, 0x0C00)) + list(range(0xF00C0, 0xF0100)), # 32: Tamil
list(range(0x980, 0xA00)), # 33: Bengali
list(range(0x2800, 0x2900)), # 34: Braille
list(range(0x1B80, 0x1BC0)) + list(range(0x1CC0, 0x1CD0)) + list(range(0xF0500, 0xF0510)), # 35: Sundanese
list(range(0xF0110, 0xF0130)), # 36: Devanagari2 Internal
list(range(0xF0520, 0xF0580)), # 37: Codestyle ASCII
list(range(0xFB00, 0xFB18)), # 38: Alphabetic Presentation
list(range(0x1B000, 0x1B170)), # 39: Hentaigana
]
CODE_RANGE_HANGUL_COMPAT = range(0x3130, 0x3190)
ALT_CHARSET_CODEPOINT_OFFSETS = [
0,
0xF0000 - 0x400, # Bulgarian
0xF0060 - 0x400, # Serbian
0xF0520 - 0x20, # Codestyle
]
ALT_CHARSET_CODEPOINT_DOMAINS = [
range(0, 0x10FFFF + 1),
range(0x400, 0x460),
range(0x400, 0x460),
range(0x20, 0x80),
]
# Unicode spacing characters
NQSP = 0x2000
MQSP = 0x2001
ENSP = 0x2002
EMSP = 0x2003
THREE_PER_EMSP = 0x2004
QUARTER_EMSP = 0x2005
SIX_PER_EMSP = 0x2006
FSP = 0x2007
PSP = 0x2008
THSP = 0x2009
HSP = 0x200A
ZWSP = 0x200B
ZWNJ = 0x200C
ZWJ = 0x200D
SHY = 0xAD
NBSP = 0xA0
OBJ = 0xFFFC
FIXED_BLOCK_1 = 0xFFFD0
MOVABLE_BLOCK_M1 = 0xFFFE0
MOVABLE_BLOCK_1 = 0xFFFF0
CHARSET_OVERRIDE_DEFAULT = 0xFFFC0
CHARSET_OVERRIDE_BG_BG = 0xFFFC1
CHARSET_OVERRIDE_SR_SR = 0xFFFC2
CHARSET_OVERRIDE_CODESTYLE = 0xFFFC3
# Alignment constants
ALIGN_LEFT = 0
ALIGN_RIGHT = 1
ALIGN_CENTRE = 2
ALIGN_BEFORE = 3
# Stack constants
STACK_UP = 0
STACK_DOWN = 1
STACK_BEFORE_N_AFTER = 2
STACK_UP_N_DOWN = 3
STACK_DONT = 4
def is_variable(filename):
return filename.endswith("_variable.tga")
def is_xy_swapped(filename):
return "xyswap" in filename.lower()
def is_extra_wide(filename):
return "extrawide" in filename.lower()
def get_cell_width(sheet_index):
"""Returns the cell pitch in the sprite sheet (includes HGAP_VAR for variable sheets)."""
fn = FILE_LIST[sheet_index]
if is_extra_wide(fn):
return W_WIDEVAR_INIT + HGAP_VAR # 32
if is_variable(fn):
return W_VAR_INIT + HGAP_VAR # 16
if sheet_index == SHEET_UNIHAN:
return W_UNIHAN
if sheet_index == SHEET_HANGUL:
return W_HANGUL_BASE
if sheet_index == SHEET_CUSTOM_SYM:
return SIZE_CUSTOM_SYM
if sheet_index == SHEET_RUNIC:
return W_LATIN_WIDE
return W_VAR_INIT + HGAP_VAR
def get_cell_height(sheet_index):
if sheet_index == SHEET_UNIHAN:
return H_UNIHAN
if sheet_index == SHEET_CUSTOM_SYM:
return SIZE_CUSTOM_SYM
return H
def get_columns(sheet_index):
if sheet_index == SHEET_UNIHAN:
return 256
return 16
# Hangul constants
JUNG_COUNT = 21
JONG_COUNT = 28
# Hangul shape arrays (sorted sets)
JUNGSEONG_I = frozenset([21, 61])
JUNGSEONG_OU = frozenset([9, 13, 14, 18, 34, 35, 39, 45, 51, 53, 54, 64, 73, 80, 83])
JUNGSEONG_OU_COMPLEX = frozenset(
[10, 11, 16] + list(range(22, 34)) + [36, 37, 38] + list(range(41, 45)) +
list(range(46, 51)) + list(range(56, 60)) + [63] + list(range(67, 73)) +
list(range(74, 80)) + list(range(81, 84)) + list(range(85, 92)) + [93, 94]
)
JUNGSEONG_RIGHTIE = frozenset([2, 4, 6, 8, 11, 16, 32, 33, 37, 42, 44, 48, 50, 71, 72, 75, 78, 79, 83, 86, 87, 88, 94])
JUNGSEONG_OEWI = frozenset([12, 15, 17, 40, 52, 55, 89, 90, 91])
JUNGSEONG_EU = frozenset([19, 62, 66])
JUNGSEONG_YI = frozenset([20, 60, 65])
JUNGSEONG_UU = frozenset([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])
JUNGSEONG_WIDE = frozenset(list(JUNGSEONG_OU) + list(JUNGSEONG_EU))
CHOSEONG_GIYEOKS = frozenset([0, 1, 15, 23, 30, 34, 45, 51, 56, 65, 82, 90, 100, 101, 110, 111, 115])
HANGUL_PEAKS_WITH_EXTRA_WIDTH = frozenset([2, 4, 6, 8, 11, 16, 32, 33, 37, 42, 44, 48, 50, 71, 75, 78, 79, 83, 86, 87, 88, 94])
GIYEOK_REMAPPING = {5: 19, 6: 20, 7: 21, 8: 22, 11: 23, 12: 24}
def is_hangul_choseong(c):
return 0x1100 <= c <= 0x115F or 0xA960 <= c <= 0xA97F
def is_hangul_jungseong(c):
return 0x1160 <= c <= 0x11A7 or 0xD7B0 <= c <= 0xD7C6
def is_hangul_jongseong(c):
return 0x11A8 <= c <= 0x11FF or 0xD7CB <= c <= 0xD7FB
def is_hangul_compat(c):
return 0x3130 <= c <= 0x318F
def to_hangul_choseong_index(c):
if 0x1100 <= c <= 0x115F:
return c - 0x1100
if 0xA960 <= c <= 0xA97F:
return c - 0xA960 + 96
raise ValueError(f"Not a choseong: U+{c:04X}")
def to_hangul_jungseong_index(c):
if 0x1160 <= c <= 0x11A7:
return c - 0x1160
if 0xD7B0 <= c <= 0xD7C6:
return c - 0xD7B0 + 72
return None
def to_hangul_jongseong_index(c):
if 0x11A8 <= c <= 0x11FF:
return c - 0x11A8 + 1
if 0xD7CB <= c <= 0xD7FB:
return c - 0xD7CB + 88 + 1
return None
def get_han_initial_row(i, p, f):
if p in JUNGSEONG_I:
ret = 3
elif p in JUNGSEONG_OEWI:
ret = 11
elif p in JUNGSEONG_OU_COMPLEX:
ret = 7
elif p in JUNGSEONG_OU:
ret = 5
elif p in JUNGSEONG_EU:
ret = 9
elif p in JUNGSEONG_YI:
ret = 13
else:
ret = 1
if f != 0:
ret += 1
if p in JUNGSEONG_UU and i in CHOSEONG_GIYEOKS:
mapped = GIYEOK_REMAPPING.get(ret)
if mapped is None:
raise ValueError(f"Giyeok remapping failed: i={i} p={p} f={f} ret={ret}")
return mapped
return ret
def get_han_medial_row(i, p, f):
return 15 if f == 0 else 16
def get_han_final_row(i, p, f):
return 17 if p not in JUNGSEONG_RIGHTIE else 18
# Kerning constants
KEMING_BIT_MASK = [1 << b for b in [7, 6, 5, 4, 3, 2, 1, 0, 15, 14]]
# Special characters for r+dot kerning
LOWERCASE_RS = frozenset([0x72, 0x155, 0x157, 0x159, 0x211, 0x213, 0x27c, 0x1e59, 0x1e58, 0x1e5f])
DOTS = frozenset([0x2c, 0x2e])
# Devanagari internal encoding
DEVANAGARI_UNICODE_NUQTA_TABLE = [0xF0170, 0xF0171, 0xF0172, 0xF0177, 0xF017C, 0xF017D, 0xF0186, 0xF018A]
def to_deva_internal(c):
if 0x0915 <= c <= 0x0939:
return c - 0x0915 + 0xF0140
if 0x0958 <= c <= 0x095F:
return DEVANAGARI_UNICODE_NUQTA_TABLE[c - 0x0958]
raise ValueError(f"No internal form for U+{c:04X}")
DEVANAGARI_CONSONANTS = frozenset(
list(range(0x0915, 0x093A)) + list(range(0x0958, 0x0960)) +
list(range(0x0978, 0x0980)) + list(range(0xF0140, 0xF0500)) +
list(range(0xF0106, 0xF010A))
)
# Sundanese internal forms
SUNDANESE_ING = 0xF0500
SUNDANESE_ENG = 0xF0501
SUNDANESE_EUNG = 0xF0502
SUNDANESE_IR = 0xF0503
SUNDANESE_ER = 0xF0504
SUNDANESE_EUR = 0xF0505
SUNDANESE_LU = 0xF0506
# Tamil constants
TAMIL_KSSA = 0xF00ED
TAMIL_SHRII = 0xF00EE
TAMIL_I = 0xBBF
TAMIL_LIGATING_CONSONANTS = [
0x0B95, 0x0B99, 0x0B9A, 0x0B9E, 0x0B9F, 0x0BA3, 0x0BA4, 0x0BA8,
0x0BA9, 0x0BAA, 0x0BAE, 0x0BAF, 0x0BB0, 0x0BB1, 0x0BB2, 0x0BB3,
0x0BB4, 0x0BB5,
]
# Devanagari special codepoints
DEVANAGARI_VIRAMA = 0x94D
DEVANAGARI_NUQTA = 0x93C
DEVANAGARI_RA = to_deva_internal(0x930)
DEVANAGARI_YA = to_deva_internal(0x92F)
DEVANAGARI_RRA = to_deva_internal(0x931)
DEVANAGARI_VA = to_deva_internal(0x935)
DEVANAGARI_HA = to_deva_internal(0x939)
DEVANAGARI_U = 0x941
DEVANAGARI_UU = 0x942
DEVANAGARI_I_VOWEL = 0x093F
DEVANAGARI_II_VOWEL = 0x0940
DEVANAGARI_RYA = 0xF0106
DEVANAGARI_HALF_RYA = 0xF0107
DEVANAGARI_OPEN_YA = 0xF0108
DEVANAGARI_OPEN_HALF_YA = 0xF0109
DEVANAGARI_ALT_HALF_SHA = 0xF010F
DEVANAGARI_EYELASH_RA = 0xF010B
DEVANAGARI_RA_SUPER = 0xF010C
DEVANAGARI_RA_SUPER_COMPLEX = 0xF010D
MARWARI_DD = 0x978
MARWARI_LIG_DD_R = 0xF010E
DEVANAGARI_SYLL_RU = 0xF0100
DEVANAGARI_SYLL_RUU = 0xF0101
DEVANAGARI_SYLL_RRU = 0xF0102
DEVANAGARI_SYLL_RRUU = 0xF0103
DEVANAGARI_SYLL_HU = 0xF0104
DEVANAGARI_SYLL_HUU = 0xF0105
# Devanagari ligature codepoints
DEVANAGARI_LIG_K_T = 0xF01BC
DEVANAGARI_LIG_K_SS = 0xF01A1
DEVANAGARI_LIG_J_NY = 0xF01A2
DEVANAGARI_LIG_T_T = 0xF01A3
DEVANAGARI_LIG_N_T = 0xF01A4
DEVANAGARI_LIG_N_N = 0xF01A5
DEVANAGARI_LIG_S_V = 0xF01A6
DEVANAGARI_LIG_SS_P = 0xF01A7
DEVANAGARI_LIG_SH_C = 0xF01A8
DEVANAGARI_LIG_SH_N = 0xF01A9
DEVANAGARI_LIG_SH_V = 0xF01AA
DEVANAGARI_LIG_J_Y = 0xF01AB
DEVANAGARI_LIG_J_J_Y = 0xF01AC
MARWARI_LIG_DD_DD = 0xF01BA
MARWARI_LIG_DD_DDH = 0xF01BB
MARWARI_LIG_DD_Y = 0xF016E
MARWARI_HALFLIG_DD_Y = 0xF016F
# Devanagari range sets for feature generation
DEVANAGARI_PRESENTATION_CONSONANTS = range(0xF0140, 0xF0230)
DEVANAGARI_PRESENTATION_CONSONANTS_HALF = range(0xF0230, 0xF0320)
DEVANAGARI_PRESENTATION_CONSONANTS_WITH_RA = range(0xF0320, 0xF0410)
DEVANAGARI_PRESENTATION_CONSONANTS_WITH_RA_HALF = range(0xF0410, 0xF0500)
# Index functions
def _kana_index_y(c):
return 12 if 0x31F0 <= c <= 0x31FF else (c - 0x3040) // 16
def _unihan_index_y(c):
return (c - 0x3400) // 256
def _devanagari_index_y(c):
return ((c - 0x0900) if c < 0xF0000 else (c - 0xF0080)) // 16
def _tamil_index_y(c):
return ((c - 0x0B80) if c < 0xF0000 else (c - 0xF0040)) // 16
def _sundanese_index_y(c):
if c >= 0xF0500:
return (c - 0xF04B0) // 16
if c < 0x1BC0:
return (c - 0x1B80) // 16
return (c - 0x1C80) // 16
def index_x(c):
return c % 16
def unihan_index_x(c):
return (c - 0x3400) % 256
def index_y(sheet_index, c):
"""Y-index (row) for codepoint c in the given sheet."""
return {
SHEET_ASCII_VARW: lambda: c // 16,
SHEET_UNIHAN: lambda: _unihan_index_y(c),
SHEET_EXTA_VARW: lambda: (c - 0x100) // 16,
SHEET_EXTB_VARW: lambda: (c - 0x180) // 16,
SHEET_KANA: lambda: _kana_index_y(c),
SHEET_CJK_PUNCT: lambda: (c - 0x3000) // 16,
SHEET_CYRILIC_VARW: lambda: (c - 0x400) // 16,
SHEET_HALFWIDTH_FULLWIDTH_VARW: lambda: (c - 0xFF00) // 16,
SHEET_UNI_PUNCT_VARW: lambda: (c - 0x2000) // 16,
SHEET_GREEK_VARW: lambda: (c - 0x370) // 16,
SHEET_THAI_VARW: lambda: (c - 0xE00) // 16,
SHEET_CUSTOM_SYM: lambda: (c - 0xE000) // 16,
SHEET_HAYEREN_VARW: lambda: (c - 0x530) // 16,
SHEET_KARTULI_VARW: lambda: (c - 0x10D0) // 16,
SHEET_IPA_VARW: lambda: (c - 0x250) // 16,
SHEET_RUNIC: lambda: (c - 0x16A0) // 16,
SHEET_LATIN_EXT_ADD_VARW: lambda: (c - 0x1E00) // 16,
SHEET_BULGARIAN_VARW: lambda: (c - 0xF0000) // 16,
SHEET_SERBIAN_VARW: lambda: (c - 0xF0060) // 16,
SHEET_TSALAGI_VARW: lambda: (c - 0x13A0) // 16,
SHEET_PHONETIC_EXT_VARW: lambda: (c - 0x1D00) // 16,
SHEET_DEVANAGARI_VARW: lambda: _devanagari_index_y(c),
SHEET_KARTULI_CAPS_VARW: lambda: (c - 0x1C90) // 16,
SHEET_DIACRITICAL_MARKS_VARW: lambda: (c - 0x300) // 16,
SHEET_GREEK_POLY_VARW: lambda: (c - 0x1F00) // 16,
SHEET_EXTC_VARW: lambda: (c - 0x2C60) // 16,
SHEET_EXTD_VARW: lambda: (c - 0xA720) // 16,
SHEET_CURRENCIES_VARW: lambda: (c - 0x20A0) // 16,
SHEET_INTERNAL_VARW: lambda: (c - 0xFFE00) // 16,
SHEET_LETTERLIKE_MATHS_VARW: lambda: (c - 0x2100) // 16,
SHEET_ENCLOSED_ALPHNUM_SUPL_VARW: lambda: (c - 0x1F100) // 16,
SHEET_TAMIL_VARW: lambda: _tamil_index_y(c),
SHEET_BENGALI_VARW: lambda: (c - 0x980) // 16,
SHEET_BRAILLE_VARW: lambda: (c - 0x2800) // 16,
SHEET_SUNDANESE_VARW: lambda: _sundanese_index_y(c),
SHEET_DEVANAGARI2_INTERNAL_VARW: lambda: (c - 0xF0110) // 16,
SHEET_CODESTYLE_ASCII_VARW: lambda: (c - 0xF0520) // 16,
SHEET_ALPHABETIC_PRESENTATION_FORMS: lambda: (c - 0xFB00) // 16,
SHEET_HENTAIGANA_VARW: lambda: (c - 0x1B000) // 16,
SHEET_HANGUL: lambda: 0,
}.get(sheet_index, lambda: c // 16)()

View File

@@ -1,133 +0,0 @@
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+0958U+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<Int, ExtractedGlyph>): Set<Int> {
val missing = mutableSetOf<Int>()
// 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)
// FB00FB06 (Latin ligatures), FB13FB17 (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
}
}

View File

@@ -1,321 +0,0 @@
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<ByteArray>, // [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<Int, ExtractedGlyph> {
val result = HashMap<Int, ExtractedGlyph>(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<String>()
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<Int, ExtractedGlyph>
) {
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<Int, ExtractedGlyph>
) {
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<Int, ExtractedGlyph>) {
// 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<ByteArray> {
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
}
}
}
}
}

View File

@@ -1,124 +0,0 @@
package net.torvald.otfbuild
import com.kreative.bitsnpicas.BitmapFontGlyph
/**
* Composes 11,172 Hangul syllables (U+AC00U+D7A3) from jamo sprite pieces.
* Also composes Hangul Compatibility Jamo (U+3130U+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<Int, Pair<BitmapFontGlyph, Int>> {
val result = HashMap<Int, Pair<BitmapFontGlyph, Int>>(12000)
// Compose Hangul Compatibility Jamo (U+3130U+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+AC00U+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<ByteArray>, b: Array<ByteArray>, w: Int, h: Int): Array<ByteArray> {
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<ByteArray>, source: Array<ByteArray>, 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<ByteArray>, 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
}
}
}

View File

@@ -1,218 +0,0 @@
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<Int, ExtractedGlyph>) {
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)
}
}

View File

@@ -1,120 +0,0 @@
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<Kem>
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<Int, ExtractedGlyph>): Map<GlyphPair, Int> {
val result = HashMap<GlyphPair, Int>()
// 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
}
}

View File

@@ -1,7 +0,0 @@
package net.torvald.otfbuild
fun main(args: Array<String>) {
val assetsDir = args.getOrElse(0) { "src/assets" }
val outputPath = args.getOrElse(1) { "OTFbuild/TerrarumSansBitmap.kbitx" }
KbitxBuilder(assetsDir).build(outputPath)
}

View File

@@ -1,377 +0,0 @@
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<List<Int>> = 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()
}

View File

@@ -1,80 +0,0 @@
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)
}
}

90
OTFbuild/tga_reader.py Normal file
View File

@@ -0,0 +1,90 @@
"""
TGA reader for uncompressed true-colour images (Type 2).
Stores pixels as RGBA8888: (R<<24 | G<<16 | B<<8 | A).
Matches the convention in TerrarumSansBitmap.kt where .and(255) checks
the alpha channel (lowest byte).
"""
import struct
from typing import List
class TgaImage:
__slots__ = ('width', 'height', 'pixels')
def __init__(self, width: int, height: int, pixels: List[int]):
self.width = width
self.height = height
self.pixels = pixels # flat array, row-major
def get_pixel(self, x: int, y: int) -> int:
"""Get pixel at (x, y) as RGBA8888 (R in bits 31-24, A in bits 7-0)."""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return 0
return self.pixels[y * self.width + x]
def read_tga(path: str) -> TgaImage:
"""Read an uncompressed true-colour TGA file."""
with open(path, 'rb') as f:
data = f.read()
pos = 0
def u8():
nonlocal pos
val = data[pos]
pos += 1
return val
def u16():
nonlocal pos
val = struct.unpack_from('<H', data, pos)[0]
pos += 2
return val
id_length = u8()
colour_map_type = u8()
image_type = u8()
# colour map spec (5 bytes)
u16(); u16(); u8()
# image spec
x_origin = u16()
y_origin = u16()
width = u16()
height = u16()
bits_per_pixel = u8()
descriptor = u8()
top_to_bottom = (descriptor & 0x20) != 0
bytes_per_pixel = bits_per_pixel // 8
# skip ID
pos += id_length
if colour_map_type != 0:
raise ValueError("Colour-mapped TGA not supported")
if image_type != 2:
raise ValueError(f"Only uncompressed true-colour TGA supported (type 2), got type {image_type}")
if bytes_per_pixel not in (3, 4):
raise ValueError(f"Only 24-bit or 32-bit TGA supported, got {bits_per_pixel}-bit")
pixels = [0] * (width * height)
for row in range(height):
y = row if top_to_bottom else (height - 1 - row)
for x in range(width):
b = data[pos]; pos += 1
g = data[pos]; pos += 1
r = data[pos]; pos += 1
a = data[pos] if bytes_per_pixel == 4 else 0xFF
if bytes_per_pixel == 4:
pos += 1
# Store as RGBA8888: R in high byte, A in low byte
pixels[y * width + x] = (r << 24) | (g << 16) | (b << 8) | a
return TgaImage(width, height, pixels)