From b3acbf1c0e293be5d16c6a6b2f9a723c4164f31a Mon Sep 17 00:00:00 2001 From: minjaesong Date: Mon, 2 Mar 2026 07:21:28 +0900 Subject: [PATCH] old hangul composition --- OTFbuild/calligra_font_tests.odt | Bin 15695 -> 15662 bytes OTFbuild/font_builder.py | 8 + OTFbuild/hangul.py | 5 +- OTFbuild/opentype_features.py | 387 +++++++++++++++++++++---------- 4 files changed, 275 insertions(+), 125 deletions(-) diff --git a/OTFbuild/calligra_font_tests.odt b/OTFbuild/calligra_font_tests.odt index 55afc04c8f8bce3bc0ebb989afeb962efb1f5b22..b8aa2ea6b26fc8e9536fa62fdd39ccdd7d2f2e83 100644 GIT binary patch delta 8264 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 a4g&>+`>)>rMp>wNI)yMjf}h|&$NvB_mEO$& delta 8277 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 HANGUL_PUA_BASE: + _pua_row = (cp - HANGUL_PUA_BASE) // 256 + if 15 <= _pua_row <= 18: + x_offset -= SC.W_HANGUL_BASE * SCALE + contours = trace_bitmap(g.bitmap, g.props.width) pen = T2CharStringPen(advance, None) diff --git a/OTFbuild/hangul.py b/OTFbuild/hangul.py index 62b6a12..f70d803 100644 --- a/OTFbuild/hangul.py +++ b/OTFbuild/hangul.py @@ -118,7 +118,10 @@ def compose_hangul(assets_dir) -> Dict[int, ExtractedGlyph]: for (col, row), bm in variants.items(): pua = _pua_for_jamo_variant(col, row) if pua not in result: - result[pua] = ExtractedGlyph(pua, GlyphProps(width=cell_w), bm) + # Jungseong (rows 15-16) and jongseong (rows 17-18) overlay the + # choseong, so they need zero advance width. + w = 0 if 15 <= row <= 18 else cell_w + result[pua] = ExtractedGlyph(pua, GlyphProps(width=w), bm) variant_count += 1 print(f" Stored {variant_count} jamo variant glyphs in PUA (0x{HANGUL_PUA_BASE:05X}+)") diff --git a/OTFbuild/opentype_features.py b/OTFbuild/opentype_features.py index 7ca5876..1e1bdbc 100644 --- a/OTFbuild/opentype_features.py +++ b/OTFbuild/opentype_features.py @@ -140,173 +140,312 @@ def _generate_hangul_gsub(glyphs, has, jamo_data): with the correct positional variant from the PUA area. The row selection logic mirrors the Kotlin code: - - Choseong row depends on which jungseong follows and whether jongseong exists + - Choseong row depends on which jungseong follows AND whether jongseong + exists (row increments by 1 when jongseong is present). Giyeok-class + choseong get remapped rows when combined with JUNGSEONG_UU. - Jungseong row is 15 (no final) or 16 (with final) - - Jongseong row is 17 (normal) or 18 (rightie jungseong) + - Jongseong row is 17 (normal) or 18 (after rightie jungseong) """ if not jamo_data: return "" pua_fn = jamo_data['pua_fn'] - # Build contextual substitution lookups - # Strategy: use ljmo/vjmo/tjmo features (standard Hangul OpenType features) - # - # ljmo: choseong → positional variant (depends on following jungseong) - # vjmo: jungseong → positional variant (depends on whether jongseong follows) - # tjmo: jongseong → positional variant (depends on preceding jungseong) + # Build codepoint lists (standard + extended jamo ranges) + cho_ranges = list(range(0x1100, 0x115F)) + list(range(0xA960, 0xA97C)) + jung_ranges = list(range(0x1161, 0x11A8)) + list(range(0xD7B0, 0xD7C7)) + jong_ranges = list(range(0x11A8, 0x1200)) + list(range(0xD7CB, 0xD7FC)) + + cho_cps = [cp for cp in cho_ranges if has(cp)] + jung_cps = [cp for cp in jung_ranges if has(cp)] + jong_cps = [cp for cp in jong_ranges if has(cp)] + + if not cho_cps or not jung_cps: + return "" + + def _jung_idx(cp): + return SC.to_hangul_jungseong_index(cp) + + def _cho_col(cp): + return SC.to_hangul_choseong_index(cp) + + def _jong_col(cp): + return SC.to_hangul_jongseong_index(cp) lines = [] - # --- ljmo: Choseong variant selection --- - # For each choseong, we need variants for different jungseong contexts. - # Row 1 is the default (basic vowels like ㅏ). - # We use contextual alternates: choseong' lookup X jungseong - ljmo_lookups = [] + # ---------------------------------------------------------------- + # Step 1: Compute choseong row mapping + # ---------------------------------------------------------------- + # Group jungseong codepoints by the choseong row they produce. + # Separate non-giyeok (general) from giyeok (remapped) mappings. + # Key: (row, has_jong) → [jung_cps] + jung_groups_general = {} # for non-giyeok choseong (i=1) + jung_groups_giyeok = {} # for giyeok choseong where row differs - # Group jungseong indices by which choseong row they select - # From getHanInitialRow: the row depends on jungseong index (p) and has-final (f) - # For GSUB, we pre-compute for f=0 (no final) since we can't know yet - row_to_jung_indices = {} - for p in range(96): # all possible jungseong indices - # Without jongseong first; use i=1 to avoid giyeok edge cases - try: - row_nf = SC.get_han_initial_row(1, p, 0) - except (ValueError, KeyError): + for jcp in jung_cps: + idx = _jung_idx(jcp) + if idx is None: continue - if row_nf not in row_to_jung_indices: - row_to_jung_indices[row_nf] = [] - row_to_jung_indices[row_nf].append(p) + for f in [0, 1]: + try: + row_ng = SC.get_han_initial_row(1, idx, f) + except (ValueError, KeyError): + continue + jung_groups_general.setdefault((row_ng, f), []).append(jcp) - # For each unique choseong row, create a lookup that substitutes - # the default choseong glyph with the variant at that row - for cho_row, jung_indices in sorted(row_to_jung_indices.items()): - if cho_row == 1: - continue # row 1 is the default, no substitution needed + # Giyeok choseong get remapped rows for JUNGSEONG_UU + if idx in SC.JUNGSEONG_UU: + try: + row_g = SC.get_han_initial_row(0, idx, f) + except (ValueError, KeyError): + continue + if row_g != row_ng: + jung_groups_giyeok.setdefault((row_g, f), []).append(jcp) + # Identify giyeok choseong codepoints + giyeok_cho_cps = [] + for ccp in cho_cps: + try: + col = _cho_col(ccp) + if col in SC.CHOSEONG_GIYEOKS: + giyeok_cho_cps.append(ccp) + except ValueError: + pass + + # Collect all unique choseong rows + all_cho_rows = set() + for (row, _f) in jung_groups_general: + all_cho_rows.add(row) + for (row, _f) in jung_groups_giyeok: + all_cho_rows.add(row) + + # ---------------------------------------------------------------- + # Step 2: Create choseong substitution lookups (one per row) + # ---------------------------------------------------------------- + cho_lookup_names = {} + for cho_row in sorted(all_cho_rows): lookup_name = f"ljmo_row{cho_row}" subs = [] - - # For standard choseong (U+1100-U+115E) - for cho_cp in range(0x1100, 0x115F): - col = cho_cp - 0x1100 + for ccp in cho_cps: + try: + col = _cho_col(ccp) + except ValueError: + continue variant_pua = pua_fn(col, cho_row) - if has(cho_cp) and has(variant_pua): - subs.append(f" sub {glyph_name(cho_cp)} by {glyph_name(variant_pua)};") - + if has(variant_pua): + subs.append(f" sub {glyph_name(ccp)} by {glyph_name(variant_pua)};") if subs: lines.append(f"lookup {lookup_name} {{") lines.extend(subs) lines.append(f"}} {lookup_name};") - ljmo_lookups.append((lookup_name, jung_indices)) + lines.append("") + cho_lookup_names[cho_row] = lookup_name - # --- vjmo: Jungseong variant selection --- - # Row 15 = no jongseong following, Row 16 = jongseong follows - # We need two lookups - vjmo_subs_16 = [] # with-final variant (row 16) - for jung_cp in range(0x1161, 0x11A8): - col = jung_cp - 0x1160 - variant_pua = pua_fn(col, 16) - if has(jung_cp) and has(variant_pua): - vjmo_subs_16.append(f" sub {glyph_name(jung_cp)} by {glyph_name(variant_pua)};") + # ---------------------------------------------------------------- + # Step 3: Create jungseong substitution lookups (row 15 and 16) + # ---------------------------------------------------------------- + vjmo_has = {} + for jung_row in [15, 16]: + lookup_name = f"vjmo_row{jung_row}" + subs = [] + for jcp in jung_cps: + idx = _jung_idx(jcp) + if idx is None: + continue + variant_pua = pua_fn(idx, jung_row) + if has(variant_pua): + subs.append(f" sub {glyph_name(jcp)} by {glyph_name(variant_pua)};") + if subs: + lines.append(f"lookup {lookup_name} {{") + lines.extend(subs) + lines.append(f"}} {lookup_name};") + lines.append("") + vjmo_has[jung_row] = True - if vjmo_subs_16: - lines.append("lookup vjmo_withfinal {") - lines.extend(vjmo_subs_16) - lines.append("} vjmo_withfinal;") + # ---------------------------------------------------------------- + # Step 4: Create jongseong substitution lookups (row 17 and 18) + # ---------------------------------------------------------------- + tjmo_has = {} + for jong_row in [17, 18]: + lookup_name = f"tjmo_row{jong_row}" + subs = [] + for jcp in jong_cps: + col = _jong_col(jcp) + if col is None: + continue + variant_pua = pua_fn(col, jong_row) + if has(variant_pua): + subs.append(f" sub {glyph_name(jcp)} by {glyph_name(variant_pua)};") + if subs: + lines.append(f"lookup {lookup_name} {{") + lines.extend(subs) + lines.append(f"}} {lookup_name};") + lines.append("") + tjmo_has[jong_row] = True - # --- tjmo: Jongseong variant selection --- - # Row 17 = normal, Row 18 = after rightie jungseong - tjmo_subs_18 = [] - for jong_cp in range(0x11A8, 0x1200): - col = jong_cp - 0x11A8 + 1 - variant_pua = pua_fn(col, 18) - if has(jong_cp) and has(variant_pua): - tjmo_subs_18.append(f" sub {glyph_name(jong_cp)} by {glyph_name(variant_pua)};") - - if tjmo_subs_18: - lines.append("lookup tjmo_rightie {") - lines.extend(tjmo_subs_18) - lines.append("} tjmo_rightie;") - - # --- Build the actual features using contextual substitution --- - - # Jungseong class definitions for contextual rules - # Build classes of jungseong glyphs that trigger specific choseong rows + # ---------------------------------------------------------------- + # Step 5: Generate ljmo feature (choseong contextual substitution) + # ---------------------------------------------------------------- feature_lines = [] - # ljmo feature: contextual choseong substitution - if ljmo_lookups: + if cho_lookup_names: feature_lines.append("feature ljmo {") feature_lines.append(" script hang;") - for lookup_name, jung_indices in ljmo_lookups: - # Build jungseong class for this row - jung_glyphs = [] - for idx in jung_indices: - cp = 0x1160 + idx - if has(cp): - jung_glyphs.append(glyph_name(cp)) - if not jung_glyphs: - continue - class_name = f"@jung_for_{lookup_name}" - feature_lines.append(f" {class_name} = [{' '.join(jung_glyphs)}];") - # Contextual rules: choseong' [lookup X] jungseong - # For each choseong, if followed by a jungseong in the right class, - # apply the variant lookup - for lookup_name, jung_indices in ljmo_lookups: - jung_glyphs = [] - for idx in jung_indices: - cp = 0x1160 + idx - if has(cp): - jung_glyphs.append(glyph_name(cp)) - if not jung_glyphs: + # Define glyph classes + cho_names = [glyph_name(c) for c in cho_cps] + feature_lines.append(f" @cho_all = [{' '.join(cho_names)}];") + + if giyeok_cho_cps: + giyeok_names = [glyph_name(c) for c in giyeok_cho_cps] + feature_lines.append(f" @cho_giyeok = [{' '.join(giyeok_names)}];") + + if jong_cps: + jong_names = [glyph_name(c) for c in jong_cps] + feature_lines.append(f" @jong_all = [{' '.join(jong_names)}];") + + # Define jungseong group classes (unique names) + cls_idx = [0] + + def _make_jung_class(jcps, prefix): + name = f"@jung_{prefix}_{cls_idx[0]}" + cls_idx[0] += 1 + feature_lines.append(f" {name} = [{' '.join(glyph_name(c) for c in jcps)}];") + return name + + # Giyeok-specific rules first (most specific: giyeok cho + UU jung) + # With-jong before without-jong for each group. + giyeok_rules = [] + for (row, f) in sorted(jung_groups_giyeok.keys()): + if row not in cho_lookup_names: continue - class_name = f"@jung_for_{lookup_name}" - # Build choseong class - cho_glyphs = [glyph_name(cp) for cp in range(0x1100, 0x115F) if has(cp)] - if cho_glyphs: - feature_lines.append(f" @choseong = [{' '.join(cho_glyphs)}];") - feature_lines.append(f" sub @choseong' lookup {lookup_name} {class_name};") + jcps = jung_groups_giyeok[(row, f)] + cls_name = _make_jung_class(jcps, "gk") + giyeok_rules.append((row, f, cls_name)) + + # Sort: with-jong (f=1) before without-jong (f=0) + for row, f, cls_name in sorted(giyeok_rules, key=lambda x: (-x[1], x[0])): + lookup = cho_lookup_names[row] + if f == 1 and jong_cps: + feature_lines.append( + f" sub @cho_giyeok' lookup {lookup} {cls_name} @jong_all;") + else: + feature_lines.append( + f" sub @cho_giyeok' lookup {lookup} {cls_name};") + + # General rules: with-jong first, then without-jong + general_rules = [] + for (row, f) in sorted(jung_groups_general.keys()): + if row not in cho_lookup_names: + continue + jcps = jung_groups_general[(row, f)] + cls_name = _make_jung_class(jcps, "ng") + general_rules.append((row, f, cls_name)) + + # With-jong rules + for row, f, cls_name in sorted(general_rules, key=lambda x: x[0]): + if f != 1: + continue + if not jong_cps: + continue + lookup = cho_lookup_names[row] + feature_lines.append( + f" sub @cho_all' lookup {lookup} {cls_name} @jong_all;") + + # Without-jong rules (fallback) + for row, f, cls_name in sorted(general_rules, key=lambda x: x[0]): + if f != 0: + continue + lookup = cho_lookup_names[row] + feature_lines.append( + f" sub @cho_all' lookup {lookup} {cls_name};") feature_lines.append("} ljmo;") + feature_lines.append("") - # vjmo feature: jungseong gets row 16 variant when followed by jongseong - if vjmo_subs_16: - jong_glyphs = [glyph_name(cp) for cp in range(0x11A8, 0x1200) if has(cp)] - if jong_glyphs: - feature_lines.append("feature vjmo {") - feature_lines.append(" script hang;") - jung_glyphs = [glyph_name(cp) for cp in range(0x1161, 0x11A8) if has(cp)] - feature_lines.append(f" @jongseong = [{' '.join(jong_glyphs)}];") - feature_lines.append(f" @jungseong = [{' '.join(jung_glyphs)}];") - feature_lines.append(f" sub @jungseong' lookup vjmo_withfinal @jongseong;") - feature_lines.append("} vjmo;") + # ---------------------------------------------------------------- + # Step 6: Generate vjmo feature (jungseong contextual substitution) + # ---------------------------------------------------------------- + if 15 in vjmo_has: + feature_lines.append("feature vjmo {") + feature_lines.append(" script hang;") - # tjmo feature: jongseong gets row 18 variant when after rightie jungseong - if tjmo_subs_18: + jung_names = [glyph_name(c) for c in jung_cps] + feature_lines.append(f" @jungseong = [{' '.join(jung_names)}];") + + if jong_cps and 16 in vjmo_has: + jong_names = [glyph_name(c) for c in jong_cps] + feature_lines.append(f" @jongseong = [{' '.join(jong_names)}];") + feature_lines.append(" sub @jungseong' lookup vjmo_row16 @jongseong;") + + # Fallback: no jongseong following → row 15 + feature_lines.append(" sub @jungseong' lookup vjmo_row15;") + + feature_lines.append("} vjmo;") + feature_lines.append("") + + # ---------------------------------------------------------------- + # Step 7: Generate tjmo feature (jongseong contextual substitution) + # ---------------------------------------------------------------- + if 17 in tjmo_has and jong_cps: + feature_lines.append("feature tjmo {") + feature_lines.append(" script hang;") + + # Rightie jungseong class: original + PUA row 15/16 variants rightie_glyphs = [] for idx in sorted(SC.JUNGSEONG_RIGHTIE): + # Original Unicode jungseong cp = 0x1160 + idx if has(cp): rightie_glyphs.append(glyph_name(cp)) - # Also check PUA variants (row 16) - pua16 = pua_fn(idx, 16) - if has(pua16): - rightie_glyphs.append(glyph_name(pua16)) - if rightie_glyphs: - feature_lines.append("feature tjmo {") - feature_lines.append(" script hang;") - feature_lines.append(f" @rightie_jung = [{' '.join(rightie_glyphs)}];") - jong_glyphs = [glyph_name(cp) for cp in range(0x11A8, 0x1200) if has(cp)] - feature_lines.append(f" @jongseong_all = [{' '.join(jong_glyphs)}];") - feature_lines.append(f" sub @rightie_jung @jongseong_all' lookup tjmo_rightie;") - feature_lines.append("} tjmo;") + # PUA variants (after vjmo substitution) + for row in [15, 16]: + pua = pua_fn(idx, row) + if has(pua): + rightie_glyphs.append(glyph_name(pua)) + # Extended jungseong that are rightie + for jcp in jung_cps: + if jcp < 0xD7B0: + continue + idx = _jung_idx(jcp) + if idx is not None and idx in SC.JUNGSEONG_RIGHTIE: + rightie_glyphs.append(glyph_name(jcp)) + + # All jungseong variants class (original + PUA row 15/16) + all_jung_variants = [] + for jcp in jung_cps: + idx = _jung_idx(jcp) + if idx is None: + continue + all_jung_variants.append(glyph_name(jcp)) + for row in [15, 16]: + pua = pua_fn(idx, row) + if has(pua): + all_jung_variants.append(glyph_name(pua)) + + jong_names = [glyph_name(c) for c in jong_cps] + feature_lines.append(f" @jongseong_all = [{' '.join(jong_names)}];") + + if rightie_glyphs and 18 in tjmo_has: + feature_lines.append( + f" @rightie_jung = [{' '.join(rightie_glyphs)}];") + feature_lines.append( + " sub @rightie_jung @jongseong_all' lookup tjmo_row18;") + + if all_jung_variants: + feature_lines.append( + f" @all_jung_variants = [{' '.join(all_jung_variants)}];") + feature_lines.append( + " sub @all_jung_variants @jongseong_all' lookup tjmo_row17;") + + feature_lines.append("} tjmo;") + feature_lines.append("") if not lines and not feature_lines: return "" - return '\n'.join(lines + [''] + feature_lines) + return '\n'.join(lines + feature_lines) def _generate_kern(kern_pairs, has):