Skip to content

Commit 9b49c12

Browse files
committed
Add Thai shaper, ported from HarfBuzz
- Decompose SARA AM (U+0E33) into NIKHAHIT + SARA AA and reorder NIKHAHIT past above-base marks, matching `preprocess_text_thai` - Apply PUA tone/vowel shift fallback for legacy fonts without Thai GSUB, mirroring `do_thai_pua_shaping` (above/below state machines + Windows/Mac PUA mapping tables) - Add Noto Sans Thai (OFL) as test fixture with four shaping tests (`น้ำ`, `ก่ำ`, `ข่ำ`, `สวัสดี`); three exercise the new decomposition + reorder path and fail without this shaper
1 parent fbf3b9e commit 9b49c12

5 files changed

Lines changed: 404 additions & 0 deletions

File tree

src/opentype/shapers/ThaiShaper.js

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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+
}

src/opentype/shapers/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import DefaultShaper from './DefaultShaper';
22
import ArabicShaper from './ArabicShaper';
33
import HangulShaper from './HangulShaper';
44
import IndicShaper from './IndicShaper';
5+
import ThaiShaper from './ThaiShaper';
56
import UniversalShaper from './UniversalShaper';
67

78
const SHAPERS = {
@@ -36,6 +37,9 @@ const SHAPERS = {
3637
tel2: IndicShaper, // Telugu
3738
khmr: IndicShaper, // Khmer
3839

40+
thai: ThaiShaper, // Thai
41+
lao: ThaiShaper, // Lao (uses Thai-equivalent reordering)
42+
3943
bali: UniversalShaper, // Balinese
4044
batk: UniversalShaper, // Batak
4145
brah: UniversalShaper, // Brahmi
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
2+
3+
This Font Software is licensed under the SIL Open Font License,
4+
Version 1.1.
5+
6+
This license is copied below, and is also available with a FAQ at:
7+
http://scripts.sil.org/OFL
8+
9+
-----------------------------------------------------------
10+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
11+
-----------------------------------------------------------
12+
13+
PREAMBLE
14+
The goals of the Open Font License (OFL) are to stimulate worldwide
15+
development of collaborative font projects, to support the font
16+
creation efforts of academic and linguistic communities, and to
17+
provide a free and open framework in which fonts may be shared and
18+
improved in partnership with others.
19+
20+
The OFL allows the licensed fonts to be used, studied, modified and
21+
redistributed freely as long as they are not sold by themselves. The
22+
fonts, including any derivative works, can be bundled, embedded,
23+
redistributed and/or sold with any software provided that any reserved
24+
names are not used by derivative works. The fonts and derivatives,
25+
however, cannot be released under any other type of license. The
26+
requirement for fonts to remain under this license does not apply to
27+
any document created using the fonts or their derivatives.
28+
29+
DEFINITIONS
30+
"Font Software" refers to the set of files released by the Copyright
31+
Holder(s) under this license and clearly marked as such. This may
32+
include source files, build scripts and documentation.
33+
34+
"Reserved Font Name" refers to any names specified as such after the
35+
copyright statement(s).
36+
37+
"Original Version" refers to the collection of Font Software
38+
components as distributed by the Copyright Holder(s).
39+
40+
"Modified Version" refers to any derivative made by adding to,
41+
deleting, or substituting -- in part or in whole -- any of the
42+
components of the Original Version, by changing formats or by porting
43+
the Font Software to a new environment.
44+
45+
"Author" refers to any designer, engineer, programmer, technical
46+
writer or other person who contributed to the Font Software.
47+
48+
PERMISSION & CONDITIONS
49+
Permission is hereby granted, free of charge, to any person obtaining
50+
a copy of the Font Software, to use, study, copy, merge, embed,
51+
modify, redistribute, and sell modified and unmodified copies of the
52+
Font Software, subject to the following conditions:
53+
54+
1) Neither the Font Software nor any of its individual components, in
55+
Original or Modified Versions, may be sold by itself.
56+
57+
2) Original or Modified Versions of the Font Software may be bundled,
58+
redistributed and/or sold with any software, provided that each copy
59+
contains the above copyright notice and this license. These can be
60+
included either as stand-alone text files, human-readable headers or
61+
in the appropriate machine-readable metadata fields within text or
62+
binary files as long as those fields can be easily viewed by the user.
63+
64+
3) No Modified Version of the Font Software may use the Reserved Font
65+
Name(s) unless explicit written permission is granted by the
66+
corresponding Copyright Holder. This restriction only applies to the
67+
primary font name as presented to the users.
68+
69+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
70+
Software shall not be used to promote, endorse or advertise any
71+
Modified Version, except to acknowledge the contribution(s) of the
72+
Copyright Holder(s) and the Author(s) or with their explicit written
73+
permission.
74+
75+
5) The Font Software, modified or unmodified, in part or in whole,
76+
must be distributed entirely under this license, and must not be
77+
distributed under any other license. The requirement for fonts to
78+
remain under this license does not apply to any document created using
79+
the Font Software.
80+
81+
TERMINATION
82+
This license becomes null and void if any of the above conditions are
83+
not met.
84+
85+
DISCLAIMER
86+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
87+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
88+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
89+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
90+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
91+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
92+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
93+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
94+
OTHER DEALINGS IN THE FONT SOFTWARE.
36.9 KB
Binary file not shown.

0 commit comments

Comments
 (0)