|
| 1 | +import DefaultShaper from './DefaultShaper'; |
| 2 | +import GlyphInfo from '../GlyphInfo'; |
| 3 | + |
| 4 | +/** |
| 5 | + * Thai / Lao shaper, ported from HarfBuzz's hb-ot-shaper-thai.cc. |
| 6 | + * |
| 7 | + * 1. SARA AM decomposition + NIKHAHIT reorder (always-on) |
| 8 | + * 2. PUA fallback shaping for legacy fonts without Thai GSUB |
| 9 | + * |
| 10 | + * Step 1 is needed by every modern Thai font — without it the GSUB chain |
| 11 | + * rules for tone-mark shifting (e.g. `uni0E49.small`) never fire because |
| 12 | + * the buffer ends up with `[base, tone, NIKHAHIT, SARA AA]` instead of |
| 13 | + * `[base, NIKHAHIT, tone, SARA AA]`. |
| 14 | + * |
| 15 | + * SARA AM (U+0E33) -> NIKHAHIT (U+0E4D) + SARA AA (U+0E32) |
| 16 | + * Lao SARA AM (U+0EB3) -> NIKHAHIT (U+0ECD) + SARA AA (U+0EB2) |
| 17 | + * |
| 18 | + * The NIKHAHIT then walks backward over any above-base marks so it sits |
| 19 | + * between the base and the existing tone-mark stack. |
| 20 | + * |
| 21 | + * <0E14, 0E4B, 0E33> -> <0E14, 0E4D, 0E4B, 0E32> |
| 22 | + * |
| 23 | + * Step 2 walks an above/below state machine and remaps tone marks to PUA |
| 24 | + * codepoints when the font ships those (older Microsoft / Apple Thai |
| 25 | + * fonts). Modern fonts with GSUB don't need this; HarfBuzz gates it on |
| 26 | + * the absence of Thai GSUB and so do we. |
| 27 | + */ |
| 28 | +export default class ThaiShaper extends DefaultShaper { |
| 29 | + static assignFeatures(plan, glyphs) { |
| 30 | + super.assignFeatures(plan, glyphs); |
| 31 | + preprocessThai(glyphs, plan.font); |
| 32 | + if (plan.script === 'thai' && !hasThaiGsub(plan.font)) { |
| 33 | + applyThaiPuaShaping(glyphs, plan.font); |
| 34 | + } |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +// Thai SARA AM is U+0E33; Lao SARA AM is U+0EB3 — they only differ in the |
| 39 | +// 0x80 bit so HarfBuzz uses a script-agnostic mask. We do the same. |
| 40 | +function isSaraAm(u) { |
| 41 | + return (u & ~0x0080) === 0x0E33; |
| 42 | +} |
| 43 | + |
| 44 | +function nikhahitFromSaraAm(u) { |
| 45 | + return u - 0x0E33 + 0x0E4D; |
| 46 | +} |
| 47 | + |
| 48 | +function saraAaFromSaraAm(u) { |
| 49 | + return u - 1; |
| 50 | +} |
| 51 | + |
| 52 | +// Marks that sit above the base. The script-agnostic mask applies the |
| 53 | +// same set for both Thai and Lao (Lao codepoints are offset by 0x80). |
| 54 | +// Thai: U+0E31, U+0E34..U+0E37, U+0E47..U+0E4E, U+0E3B |
| 55 | +// Lao: U+0EB1, U+0EB4..U+0EB7, U+0EC8..U+0ECE, U+0EBB |
| 56 | +function isAboveBaseMark(u) { |
| 57 | + const c = u & ~0x0080; |
| 58 | + return c === 0x0E31 |
| 59 | + || (c >= 0x0E34 && c <= 0x0E37) |
| 60 | + || (c >= 0x0E47 && c <= 0x0E4E) |
| 61 | + || c === 0x0E3B; |
| 62 | +} |
| 63 | + |
| 64 | +function preprocessThai(glyphs, font) { |
| 65 | + let i = 0; |
| 66 | + while (i < glyphs.length) { |
| 67 | + const u = glyphs[i].codePoints[0]; |
| 68 | + if (!isSaraAm(u)) { |
| 69 | + i++; |
| 70 | + continue; |
| 71 | + } |
| 72 | + |
| 73 | + // Decompose SARA AM in place into NIKHAHIT + SARA AA. Both new |
| 74 | + // glyphs inherit the original GlyphInfo's feature flags. |
| 75 | + const features = glyphs[i].features; |
| 76 | + const nikhahit = makeGlyph(font, nikhahitFromSaraAm(u), features); |
| 77 | + const saraAa = makeGlyph(font, saraAaFromSaraAm(u), features); |
| 78 | + glyphs.splice(i, 1, nikhahit, saraAa); |
| 79 | + |
| 80 | + // Walk the NIKHAHIT backward over any above-base marks belonging to |
| 81 | + // the same base. |
| 82 | + let nikhahitIndex = i; |
| 83 | + let target = nikhahitIndex; |
| 84 | + while (target > 0 && isAboveBaseMark(glyphs[target - 1].codePoints[0])) { |
| 85 | + target--; |
| 86 | + } |
| 87 | + if (target !== nikhahitIndex) { |
| 88 | + const moved = glyphs.splice(nikhahitIndex, 1)[0]; |
| 89 | + glyphs.splice(target, 0, moved); |
| 90 | + } |
| 91 | + |
| 92 | + // Advance past NIKHAHIT + SARA AA. |
| 93 | + i += 2; |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +function makeGlyph(font, codePoint, features) { |
| 98 | + const id = font.glyphForCodePoint(codePoint).id; |
| 99 | + return new GlyphInfo(font, id, [codePoint], features); |
| 100 | +} |
| 101 | + |
| 102 | +// ── PUA fallback shaping ──────────────────────────────────────────────── |
| 103 | +// |
| 104 | +// Walks an above-base state machine and a below-base state machine in |
| 105 | +// parallel. Each tone/vowel mark may trigger one of the following |
| 106 | +// actions: |
| 107 | +// |
| 108 | +// NOP — leave the glyph alone |
| 109 | +// SD — shift the mark DOWN to clear a descender |
| 110 | +// SL — shift the mark LEFT to clear another above-base mark |
| 111 | +// SDL — shift the mark DOWN-LEFT (both) |
| 112 | +// RD — remove the descender from the BASE consonant |
| 113 | +// |
| 114 | +// Each action is realised by replacing the mark (or base) codepoint with |
| 115 | +// a private-use mapping, when the font ships that PUA glyph. |
| 116 | + |
| 117 | +const NOP = 0; |
| 118 | +const SD = 1; |
| 119 | +const SL = 2; |
| 120 | +const SDL = 3; |
| 121 | +const RD = 4; |
| 122 | + |
| 123 | +// Consonant types |
| 124 | +const NC = 0; // normal consonant |
| 125 | +const AC = 1; // consonant with ascender (1B/1D/1F) |
| 126 | +const RC = 2; // consonant with removable descender (0D/10) |
| 127 | +const DC = 3; // consonant with strict descender (0E/0F) |
| 128 | +const NOT_CONSONANT = 4; |
| 129 | + |
| 130 | +// Mark types |
| 131 | +const AV = 0; // above-base vowel/mark |
| 132 | +const BV = 1; // below-base vowel/mark |
| 133 | +const T = 2; // tone mark |
| 134 | +const NOT_MARK = 3; |
| 135 | + |
| 136 | +function getConsonantType(u) { |
| 137 | + if (u === 0x0E1B || u === 0x0E1D || u === 0x0E1F) return AC; |
| 138 | + if (u === 0x0E0D || u === 0x0E10) return RC; |
| 139 | + if (u === 0x0E0E || u === 0x0E0F) return DC; |
| 140 | + if (u >= 0x0E01 && u <= 0x0E2E) return NC; |
| 141 | + return NOT_CONSONANT; |
| 142 | +} |
| 143 | + |
| 144 | +function getMarkType(u) { |
| 145 | + if ( |
| 146 | + u === 0x0E31 || |
| 147 | + (u >= 0x0E34 && u <= 0x0E37) || |
| 148 | + u === 0x0E47 || |
| 149 | + (u >= 0x0E4D && u <= 0x0E4E) |
| 150 | + ) { |
| 151 | + return AV; |
| 152 | + } |
| 153 | + if (u >= 0x0E38 && u <= 0x0E3A) return BV; |
| 154 | + if (u >= 0x0E48 && u <= 0x0E4C) return T; |
| 155 | + return NOT_MARK; |
| 156 | +} |
| 157 | + |
| 158 | +// Above-base cluster state (T0..T3 = increasing stack height). |
| 159 | +const T0 = 0, T1 = 1, T2 = 2, T3 = 3; |
| 160 | +const ABOVE_START_STATE = [T0, T1, T0, T0, T3]; |
| 161 | +// NC AC RC DC NOT_CONSONANT |
| 162 | +const ABOVE_STATE_MACHINE = [ |
| 163 | + // AV BV T |
| 164 | + [[NOP, T3], [NOP, T0], [SD, T3]], // T0 |
| 165 | + [[SL, T2], [NOP, T1], [SDL, T2]], // T1 |
| 166 | + [[NOP, T3], [NOP, T2], [SL, T3]], // T2 |
| 167 | + [[NOP, T3], [NOP, T3], [NOP, T3]] // T3 |
| 168 | +]; |
| 169 | + |
| 170 | +// Below-base state (B0=none, B1=removable, B2=strict). |
| 171 | +const B0 = 0, B1 = 1, B2 = 2; |
| 172 | +const BELOW_START_STATE = [B0, B0, B1, B2, B2]; |
| 173 | +// NC AC RC DC NOT_CONSONANT |
| 174 | +const BELOW_STATE_MACHINE = [ |
| 175 | + // AV BV T |
| 176 | + [[NOP, B0], [NOP, B2], [NOP, B0]], // B0 |
| 177 | + [[NOP, B1], [RD, B2], [NOP, B1]], // B1 |
| 178 | + [[NOP, B2], [SD, B2], [NOP, B2]] // B2 |
| 179 | +]; |
| 180 | + |
| 181 | +// PUA mappings (Windows and Mac private-use codepoints for shifted marks |
| 182 | +// and descender-less base consonants). For each action we try the |
| 183 | +// Windows PUA first, then the Mac PUA, then leave the codepoint alone. |
| 184 | +const PUA_MAPPINGS = { |
| 185 | + [SD]: [ |
| 186 | + [0x0E48, 0xF70A, 0xF88B], // MAI EK |
| 187 | + [0x0E49, 0xF70B, 0xF88E], // MAI THO |
| 188 | + [0x0E4A, 0xF70C, 0xF891], // MAI TRI |
| 189 | + [0x0E4B, 0xF70D, 0xF894], // MAI CHATTAWA |
| 190 | + [0x0E4C, 0xF70E, 0xF897], // THANTHAKHAT |
| 191 | + [0x0E38, 0xF718, 0xF89B], // SARA U |
| 192 | + [0x0E39, 0xF719, 0xF89C], // SARA UU |
| 193 | + [0x0E3A, 0xF71A, 0xF89D] // PHINTHU |
| 194 | + ], |
| 195 | + [SDL]: [ |
| 196 | + [0x0E48, 0xF705, 0xF88C], // MAI EK |
| 197 | + [0x0E49, 0xF706, 0xF88F], // MAI THO |
| 198 | + [0x0E4A, 0xF707, 0xF892], // MAI TRI |
| 199 | + [0x0E4B, 0xF708, 0xF895], // MAI CHATTAWA |
| 200 | + [0x0E4C, 0xF709, 0xF898] // THANTHAKHAT |
| 201 | + ], |
| 202 | + [SL]: [ |
| 203 | + [0x0E48, 0xF713, 0xF88A], // MAI EK |
| 204 | + [0x0E49, 0xF714, 0xF88D], // MAI THO |
| 205 | + [0x0E4A, 0xF715, 0xF890], // MAI TRI |
| 206 | + [0x0E4B, 0xF716, 0xF893], // MAI CHATTAWA |
| 207 | + [0x0E4C, 0xF717, 0xF896], // THANTHAKHAT |
| 208 | + [0x0E31, 0xF710, 0xF884], // MAI HAN-AKAT |
| 209 | + [0x0E34, 0xF701, 0xF885], // SARA I |
| 210 | + [0x0E35, 0xF702, 0xF886], // SARA II |
| 211 | + [0x0E36, 0xF703, 0xF887], // SARA UE |
| 212 | + [0x0E37, 0xF704, 0xF888], // SARA UEE |
| 213 | + [0x0E47, 0xF712, 0xF889], // MAITAIKHU |
| 214 | + [0x0E4D, 0xF711, 0xF899] // NIKHAHIT |
| 215 | + ], |
| 216 | + [RD]: [ |
| 217 | + [0x0E0D, 0xF70F, 0xF89A], // YO YING |
| 218 | + [0x0E10, 0xF700, 0xF89E] // THO THAN |
| 219 | + ] |
| 220 | +}; |
| 221 | + |
| 222 | +function thaiPuaShape(u, action, font) { |
| 223 | + if (action === NOP) return u; |
| 224 | + const mappings = PUA_MAPPINGS[action]; |
| 225 | + if (!mappings) return u; |
| 226 | + for (const [orig, winPua, macPua] of mappings) { |
| 227 | + if (orig !== u) continue; |
| 228 | + if (font.hasGlyphForCodePoint(winPua)) return winPua; |
| 229 | + if (font.hasGlyphForCodePoint(macPua)) return macPua; |
| 230 | + break; |
| 231 | + } |
| 232 | + return u; |
| 233 | +} |
| 234 | + |
| 235 | +function applyThaiPuaShaping(glyphs, font) { |
| 236 | + let aboveState = ABOVE_START_STATE[NOT_CONSONANT]; |
| 237 | + let belowState = BELOW_START_STATE[NOT_CONSONANT]; |
| 238 | + let baseIndex = 0; |
| 239 | + |
| 240 | + for (let i = 0; i < glyphs.length; i++) { |
| 241 | + const u = glyphs[i].codePoints[0]; |
| 242 | + const mt = getMarkType(u); |
| 243 | + |
| 244 | + if (mt === NOT_MARK) { |
| 245 | + const ct = getConsonantType(u); |
| 246 | + aboveState = ABOVE_START_STATE[ct]; |
| 247 | + belowState = BELOW_START_STATE[ct]; |
| 248 | + baseIndex = i; |
| 249 | + continue; |
| 250 | + } |
| 251 | + |
| 252 | + const [aboveAction, aboveNext] = ABOVE_STATE_MACHINE[aboveState][mt]; |
| 253 | + const [belowAction, belowNext] = BELOW_STATE_MACHINE[belowState][mt]; |
| 254 | + aboveState = aboveNext; |
| 255 | + belowState = belowNext; |
| 256 | + |
| 257 | + // At least one of the two actions is NOP; the other wins. |
| 258 | + const action = aboveAction !== NOP ? aboveAction : belowAction; |
| 259 | + if (action === NOP) continue; |
| 260 | + |
| 261 | + if (action === RD) { |
| 262 | + const target = glyphs[baseIndex]; |
| 263 | + const newCp = thaiPuaShape(target.codePoints[0], action, font); |
| 264 | + if (newCp !== target.codePoints[0]) { |
| 265 | + target.id = font.glyphForCodePoint(newCp).id; |
| 266 | + target.codePoints = [newCp]; |
| 267 | + } |
| 268 | + } else { |
| 269 | + const target = glyphs[i]; |
| 270 | + const newCp = thaiPuaShape(u, action, font); |
| 271 | + if (newCp !== u) { |
| 272 | + target.id = font.glyphForCodePoint(newCp).id; |
| 273 | + target.codePoints = [newCp]; |
| 274 | + } |
| 275 | + } |
| 276 | + } |
| 277 | +} |
| 278 | + |
| 279 | +// HarfBuzz gates PUA shaping on the font lacking a Thai GSUB script |
| 280 | +// (`plan->map.found_script[0]` is false). For fontkit we check whether |
| 281 | +// the GSUB script list contains `thai` or `thai2`. |
| 282 | +function hasThaiGsub(font) { |
| 283 | + const gsub = font.GSUB; |
| 284 | + if (!gsub || !gsub.scriptList) return false; |
| 285 | + return gsub.scriptList.some(entry => entry.tag === 'thai' || entry.tag === 'tha2'); |
| 286 | +} |
0 commit comments