diff --git a/OTFbuild/build_font.py b/OTFbuild/build_font.py index 9b8dfa9..f9156f2 100644 --- a/OTFbuild/build_font.py +++ b/OTFbuild/build_font.py @@ -6,7 +6,7 @@ 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 + python3 OTFbuild/build_font.py src/assets -o OTFbuild/TerrarumSansBitmap.otf Options: --no-bitmap Skip EBDT/EBLC bitmap strike diff --git a/OTFbuild/font_builder.py b/OTFbuild/font_builder.py index 141da9a..650b751 100644 --- a/OTFbuild/font_builder.py +++ b/OTFbuild/font_builder.py @@ -102,11 +102,86 @@ def build_font(assets_dir, output_path, no_bitmap=False, no_features=False): glyphs.update(hangul_glyphs) print(f" Total glyphs after Hangul: {len(glyphs)}") + # Step 2b: Copy PUA consonant glyphs to Unicode positions + # In the bitmap font, consonants U+0915-0939 have width=0 and empty bitmaps + # because the engine normalises them to PUA forms (0xF0140+) before rendering. + # For OTF, we need the Unicode positions to have actual outlines so that + # consonants render even without GSUB shaping. + print("Step 2b: Populating Devanagari consonant glyphs from PUA forms...") + deva_copied = 0 + for uni_cp in range(0x0915, 0x093A): + try: + pua_cp = SC.to_deva_internal(uni_cp) + except ValueError: + continue + if pua_cp in glyphs and uni_cp in glyphs: + pua_g = glyphs[pua_cp] + uni_g = glyphs[uni_cp] + if uni_g.props.width == 0 and pua_g.props.width > 0: + uni_g.props.width = pua_g.props.width + uni_g.bitmap = pua_g.bitmap + deva_copied += 1 + # Also copy nukta consonant forms U+0958-095F + for uni_cp in range(0x0958, 0x0960): + try: + pua_cp = SC.to_deva_internal(uni_cp) + except ValueError: + continue + if pua_cp in glyphs and uni_cp in glyphs: + pua_g = glyphs[pua_cp] + uni_g = glyphs[uni_cp] + if uni_g.props.width == 0 and pua_g.props.width > 0: + uni_g.props.width = pua_g.props.width + uni_g.bitmap = pua_g.bitmap + deva_copied += 1 + print(f" Copied {deva_copied} consonant glyphs from PUA forms") + # Step 3: Expand replacewith directives print("Step 3: Processing replacewith directives...") replacewith_subs = _expand_replacewith(glyphs) print(f" Found {len(replacewith_subs)} replacewith substitutions") + # Step 3b: Compose fallback bitmaps for replacewith glyphs + # Glyphs with replacewith directives have width=0 and no bitmap; they + # rely on GSUB ccmp to expand into their target sequence. Renderers + # without GSUB support would show whitespace. Build a composite + # bitmap by concatenating the target glyphs' bitmaps side by side. + print("Step 3b: Composing fallback bitmaps for replacewith glyphs...") + composed = 0 + for src_cp, target_cps in replacewith_subs: + src_g = glyphs.get(src_cp) + if src_g is None or src_g.props.width > 0: + continue # already has content (e.g. Deva consonants fixed above) + # Resolve target glyphs + target_gs = [glyphs.get(t) for t in target_cps] + if not all(target_gs): + continue + # Compute total advance and composite height + total_width = sum(g.props.width for g in target_gs) + if total_width == 0: + continue + bm_height = max((len(g.bitmap) for g in target_gs if g.bitmap), default=SC.H) + # Build composite bitmap + composite = [[0] * total_width for _ in range(bm_height)] + x = 0 + for tg in target_gs: + if not tg.bitmap: + x += tg.props.width + continue + cols = min(tg.props.width, len(tg.bitmap[0])) if tg.props.width > 0 else len(tg.bitmap[0]) + for row in range(min(len(tg.bitmap), bm_height)): + for col in range(cols): + dst_col = x + col + if dst_col < total_width and tg.bitmap[row][col]: + composite[row][dst_col] = 1 + if tg.props.width > 0: + x += tg.props.width + # Zero-width targets (combining marks) overlay at current position + src_g.props.width = total_width + src_g.bitmap = composite + composed += 1 + print(f" Composed {composed} fallback bitmaps") + # Step 4: Create glyph order and cmap print("Step 4: Building glyph order and cmap...") glyph_order = [".notdef"] diff --git a/OTFbuild/opentype_features.py b/OTFbuild/opentype_features.py index 3320477..5fce060 100644 --- a/OTFbuild/opentype_features.py +++ b/OTFbuild/opentype_features.py @@ -73,7 +73,7 @@ def generate_features(glyphs, kern_pairs, font_glyph_set, parts.append(locl_code) # Devanagari features - deva_code = _generate_devanagari(glyphs, has) + deva_code = _generate_devanagari(glyphs, has, replacewith_subs or []) if deva_code: parts.append(deva_code) @@ -397,13 +397,11 @@ def _generate_locl(glyphs, has): return '\n'.join(lines) -def _generate_devanagari(glyphs, has): - """Generate Devanagari GSUB features: ccmp (consonant mapping), nukt, akhn, half, vatu, pres, blws, rphf.""" +def _generate_devanagari(glyphs, has, replacewith_subs=None): + """Generate Devanagari GSUB features: ccmp (consonant mapping + vowel decomposition), nukt, akhn, half, vatu, pres, blws, rphf.""" features = [] # --- ccmp: Map Unicode consonants to internal PUA presentation forms --- - # This is the critical first step: U+0915-0939 have width=0 in the sheet, - # the actual glyph bitmaps live at their PUA forms (0xF0140+). # This mirrors the Kotlin normalise() pass 0. ccmp_subs = [] for uni_cp in range(0x0915, 0x093A): @@ -422,13 +420,42 @@ def _generate_devanagari(glyphs, has): ) except ValueError: pass - if ccmp_subs: - features.append( - "feature ccmp {\n script dev2;\n" - " lookup DevaConsonantMap {\n" - + '\n'.join(" " + s for s in ccmp_subs) - + "\n } DevaConsonantMap;\n} ccmp;" - ) + + # --- ccmp: Devanagari vowel decompositions --- + # Independent vowels like U+0910 (AI) decompose into base + matra. + # These must be in the dev2 ccmp so HarfBuzz applies them during + # Devanagari shaping (DFLT ccmp is not used when dev2 is present). + vowel_decomp_subs = [] + if replacewith_subs: + for src_cp, target_cps in replacewith_subs: + if not (0x0900 <= src_cp <= 0x097F): + continue + # Skip consonants (already handled above as single subs) + if 0x0915 <= src_cp <= 0x0939 or 0x0958 <= src_cp <= 0x095F: + continue + if len(target_cps) < 2: + continue + if not has(src_cp): + continue + if not all(has(t) for t in target_cps): + continue + targets = ' '.join(glyph_name(t) for t in target_cps) + vowel_decomp_subs.append( + f" sub {glyph_name(src_cp)} by {targets};" + ) + + if ccmp_subs or vowel_decomp_subs: + ccmp_parts = ["feature ccmp {", " script dev2;"] + if ccmp_subs: + ccmp_parts.append(" lookup DevaConsonantMap {") + ccmp_parts.extend(" " + s for s in ccmp_subs) + ccmp_parts.append(" } DevaConsonantMap;") + if vowel_decomp_subs: + ccmp_parts.append(" lookup DevaVowelDecomp {") + ccmp_parts.extend(" " + s for s in vowel_decomp_subs) + ccmp_parts.append(" } DevaVowelDecomp;") + ccmp_parts.append("} ccmp;") + features.append('\n'.join(ccmp_parts)) # --- nukt: consonant + nukta -> nukta form --- # Now operates on PUA forms (after ccmp)