@mark1russell7/typeset 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +21 -1
  2. package/build/chunks/engines.d.ts +32 -0
  3. package/build/chunks/engines.d.ts.map +1 -0
  4. package/build/chunks/engines.js +35 -0
  5. package/build/chunks/engines.js.map +1 -0
  6. package/build/chunks/font.d.ts +203 -0
  7. package/build/chunks/font.d.ts.map +1 -0
  8. package/build/chunks/font.js +3550 -0
  9. package/build/chunks/font.js.map +1 -0
  10. package/build/chunks/fonts.d.ts +8 -0
  11. package/build/chunks/fonts.d.ts.map +1 -0
  12. package/build/chunks/fonts.js +13 -0
  13. package/build/chunks/fonts.js.map +1 -0
  14. package/build/chunks/measurer.d.ts +67 -0
  15. package/build/chunks/measurer.d.ts.map +1 -0
  16. package/build/chunks/measurer.js +277 -0
  17. package/build/chunks/measurer.js.map +1 -0
  18. package/build/chunks/sfnt.d.ts +22 -0
  19. package/build/chunks/sfnt.d.ts.map +1 -0
  20. package/build/chunks/text.js.map +1 -1
  21. package/build/fonts-inspect.d.ts +10 -0
  22. package/build/fonts-inspect.d.ts.map +1 -0
  23. package/build/fonts-inspect.js +1 -0
  24. package/build/fonts.d.ts +5 -0
  25. package/build/fonts.js +4 -0
  26. package/build/index.d.ts +3 -1
  27. package/build/index.js +3 -1
  28. package/build/measurer.d.ts +2 -0
  29. package/build/measurer.js +1 -0
  30. package/package.json +17 -3
  31. package/src/fonts/bidi.ts +125 -0
  32. package/src/fonts/binary.ts +41 -0
  33. package/src/fonts/engines.ts +38 -0
  34. package/src/fonts/font.ts +442 -0
  35. package/src/fonts/gvar.ts +220 -0
  36. package/src/fonts/index.ts +10 -0
  37. package/src/fonts/indic.ts +585 -0
  38. package/src/fonts/joining.ts +141 -0
  39. package/src/fonts/kerning.ts +217 -0
  40. package/src/fonts/sfnt.ts +102 -0
  41. package/src/fonts/substitution.ts +586 -0
  42. package/src/fonts/tables.ts +140 -0
  43. package/src/fonts/variations.ts +175 -0
  44. package/src/fonts/words.ts +119 -0
  45. package/src/fonts-inspect.ts +8 -0
  46. package/src/fonts.ts +8 -0
  47. package/src/index.ts +2 -0
  48. package/src/measurer/advance.ts +307 -0
  49. package/src/measurer.ts +5 -0
  50. package/src/text/rope.ts +1 -1
@@ -0,0 +1,585 @@
1
+ /**
2
+ * The Indic shaper of HarfBuzz for the script Devanagari. This module follows `hb-ot-shaper-indic.cc`, the syllable machine
3
+ * of `hb-ot-shaper-indic-machine.rl`, the dotted circles of `hb-ot-shaper-syllabic.cc` and `hb-ot-shaper-vowel-constraints.cc`,
4
+ * and the normalization of `hb-ot-shape-normalize.cc`. The table of the categories comes from `hb-ot-shaper-indic-table.cc`.
5
+ */
6
+ import type { Font } from "./font.ts";
7
+ import { LIGATED, MULTIPLIED, SUBSTITUTED, type GlyphInfo } from "./substitution.ts";
8
+
9
+ /** The OpenType script tags that use the Indic shaper in this model. */
10
+ export const indicScripts: readonly string[] = ["deva"];
11
+
12
+ // The categories of the Indic table of HarfBuzz
13
+ const X = 0;
14
+ const C = 1;
15
+ const V = 2;
16
+ const N = 3;
17
+ const H = 4;
18
+ const ZWNJ = 5;
19
+ const ZWJ = 6;
20
+ const M = 7;
21
+ const SM = 8;
22
+ const A = 9;
23
+ const PLACEHOLDER = 10;
24
+ const DOTTEDCIRCLE = 11;
25
+ const RS = 12;
26
+ const MPST = 13;
27
+ const REPHA = 14;
28
+ const RA = 15;
29
+ const CM = 16;
30
+ const SYMBOL = 17;
31
+ const CS = 18;
32
+ const SMPST = 57;
33
+
34
+ // The positions of the Indic table of HarfBuzz, in the order of the glyphs of a syllable
35
+ const START = 0;
36
+ const RA_TO_BECOME_REPH = 1;
37
+ const PRE_M = 2;
38
+ const PRE_C = 3;
39
+ const BASE_C = 4;
40
+ const BELOW_C = 8;
41
+ const AFTER_SUB = 9;
42
+ const POST_C = 11;
43
+ const SMVD = 13;
44
+ const END = 14;
45
+
46
+ // The syllable types of the machine
47
+ const CONSONANT_SYLLABLE = 0;
48
+ const VOWEL_SYLLABLE = 1;
49
+ const STANDALONE_CLUSTER = 2;
50
+ const SYMBOL_CLUSTER = 3;
51
+ const BROKEN_CLUSTER = 4;
52
+ const NON_INDIC_CLUSTER = 5;
53
+
54
+ /**
55
+ * The categories and positions of the Indic table of HarfBuzz for the characters that Devanagari text uses. Each group of
56
+ * four has the first code point, the last code point, the category and the position. Another code point has the category
57
+ * `X` and the position `END`.
58
+ */
59
+ const indicTable: readonly number[] = [
60
+ 0x2d, 0x2d, PLACEHOLDER, BASE_C, 0x30, 0x39, PLACEHOLDER, BASE_C, 0xa0, 0xa0, PLACEHOLDER, BASE_C, 0xb2, 0xb3, SMPST, SMVD,
61
+ 0xd7, 0xd7, PLACEHOLDER, BASE_C, 0x900, 0x903, SM, SMVD, 0x904, 0x914, V, BASE_C, 0x915, 0x92f, C, BASE_C,
62
+ 0x930, 0x930, RA, BASE_C, 0x931, 0x939, C, BASE_C, 0x93a, 0x93b, M, AFTER_SUB, 0x93c, 0x93c, N, END,
63
+ 0x93d, 0x93d, SYMBOL, SMVD, 0x93e, 0x93e, M, AFTER_SUB, 0x93f, 0x93f, M, PRE_M, 0x940, 0x94c, M, AFTER_SUB,
64
+ 0x94d, 0x94d, H, BELOW_C, 0x94e, 0x94e, M, PRE_M, 0x94f, 0x94f, M, AFTER_SUB, 0x951, 0x952, A, SMVD,
65
+ 0x953, 0x954, SM, SMVD, 0x955, 0x957, M, AFTER_SUB, 0x958, 0x95f, C, BASE_C, 0x960, 0x961, V, BASE_C,
66
+ 0x962, 0x963, M, AFTER_SUB, 0x966, 0x96f, PLACEHOLDER, BASE_C, 0x972, 0x977, V, BASE_C, 0x978, 0x97f, C, BASE_C,
67
+ 0x1cd0, 0x1cd2, A, SMVD, 0x1cd4, 0x1ce8, A, SMVD, 0x1ce9, 0x1cec, SYMBOL, SMVD, 0x1ced, 0x1ced, A, SMVD,
68
+ 0x1cee, 0x1cf1, SYMBOL, SMVD, 0x1cf2, 0x1cf3, C, BASE_C, 0x1cf4, 0x1cf4, A, SMVD, 0x1cf5, 0x1cf6, CS, BASE_C,
69
+ 0x1cf7, 0x1cf9, A, SMVD, 0x1cfa, 0x1cfa, PLACEHOLDER, BASE_C, 0x200c, 0x200c, ZWNJ, END, 0x200d, 0x200d, ZWJ, END,
70
+ 0x2010, 0x2015, PLACEHOLDER, BASE_C, 0x2022, 0x2022, PLACEHOLDER, BASE_C, 0x2074, 0x2074, SMPST, SMVD,
71
+ 0x2082, 0x2084, SMPST, SMVD, 0x25cc, 0x25cc, DOTTEDCIRCLE, BASE_C, 0xa8e0, 0xa8f1, A, SMVD, 0xa8f2, 0xa8f7, SYMBOL, SMVD,
72
+ 0xa8fe, 0xa8fe, V, BASE_C, 0xa8ff, 0xa8ff, M, AFTER_SUB,
73
+ ];
74
+
75
+ /** This function gives the category and the position of a code point. */
76
+ function indicProperties(codePoint: number): [number, number] {
77
+ let low = 0;
78
+ let high = indicTable.length / 4 - 1;
79
+ while (low <= high) {
80
+ const middle = (low + high) >> 1;
81
+ if (codePoint < indicTable[middle * 4]!) high = middle - 1;
82
+ else if (codePoint > indicTable[middle * 4 + 1]!) low = middle + 1;
83
+ else return [indicTable[middle * 4 + 2]!, indicTable[middle * 4 + 3]!];
84
+ }
85
+ return [X, END];
86
+ }
87
+
88
+ /**
89
+ * The canonical combining classes of the marks of the Devanagari blocks that are not 0. The normalization uses them to
90
+ * sort the marks and to block a composition. A mark of another block has the class 0 in this model.
91
+ */
92
+ const combiningClasses: readonly number[] = [
93
+ 0x93c, 0x93c, 7, 0x94d, 0x94d, 9, 0x951, 0x951, 230, 0x952, 0x952, 220, 0x953, 0x954, 230, 0x1cd0, 0x1cd2, 230,
94
+ 0x1cd4, 0x1cd4, 1, 0x1cd5, 0x1cd9, 220, 0x1cda, 0x1cdb, 230, 0x1cdc, 0x1cdf, 220, 0x1ce0, 0x1ce0, 230, 0x1ce2, 0x1ce8, 1,
95
+ 0x1ced, 0x1ced, 220, 0x1cf4, 0x1cf4, 230, 0x1cf8, 0x1cf9, 230, 0xa8e0, 0xa8f1, 230,
96
+ ];
97
+
98
+ function combiningClass(codePoint: number): number {
99
+ for (let i = 0; i < combiningClasses.length; i += 3) if (codePoint >= combiningClasses[i]! && codePoint <= combiningClasses[i + 1]!) return combiningClasses[i + 2]!;
100
+ return 0;
101
+ }
102
+
103
+ const unicodeMark = /\p{M}/u;
104
+ /** The general categories from `FORMAT` to `NON_SPACING_MARK` of HarfBuzz: a letter, a mark, or a format or other code point. */
105
+ const letterOrMark = /[\p{L}\p{M}\p{Cf}\p{Cn}\p{Co}\p{Cs}]/u;
106
+
107
+ function isMark(codePoint: number): boolean {
108
+ return unicodeMark.test(String.fromCodePoint(codePoint));
109
+ }
110
+
111
+ /**
112
+ * This function inserts a dotted circle in each vowel sequence of Devanagari that looks like another vowel, as
113
+ * `_hb_preprocess_text_vowel_constraints` does.
114
+ */
115
+ function vowelConstraints(codePoints: readonly number[]): number[] {
116
+ const out: number[] = [];
117
+ let i = 0;
118
+ while (i + 1 < codePoints.length) {
119
+ const first = codePoints[i]!;
120
+ const second = codePoints[i + 1]!;
121
+ let matched = false;
122
+ if (first === 0x905) matched = [0x93a, 0x93b, 0x93e, 0x945, 0x946, 0x949, 0x94a, 0x94b, 0x94c, 0x94f, 0x956, 0x957].includes(second);
123
+ else if (first === 0x906) matched = [0x93a, 0x945, 0x946, 0x947, 0x948].includes(second);
124
+ else if (first === 0x909) matched = second === 0x941;
125
+ else if (first === 0x90f) matched = [0x945, 0x946, 0x947].includes(second);
126
+ else if (first === 0x930 && second === 0x94d && codePoints[i + 2] === 0x907) {
127
+ out.push(codePoints[i++]!);
128
+ matched = true;
129
+ }
130
+ out.push(codePoints[i++]!);
131
+ if (matched) out.push(0x25cc, codePoints[i++]!);
132
+ }
133
+ while (i < codePoints.length) out.push(codePoints[i++]!);
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * This function normalizes the code points as HarfBuzz does in the mode `COMPOSED_DIACRITICS_NO_SHORT_CIRCUIT`. It
139
+ * decomposes each character that has a canonical decomposition when the font has the glyphs of its parts. The letter
140
+ * U+0931 does not decompose. A text with a mark after its first character then gets more steps. The function sorts the
141
+ * marks, and composes each mark with its starter again when the font has the glyph of the composite.
142
+ */
143
+ function normalize(codePoints: readonly number[], hasGlyph: (codePoint: number) => boolean): number[] {
144
+ const allSimple = !codePoints.some((codePoint, i) => i > 0 && isMark(codePoint));
145
+ const decomposed: number[] = [];
146
+ for (const codePoint of codePoints) {
147
+ const parts = codePoint === 0x931 ? [codePoint] : [...String.fromCodePoint(codePoint).normalize("NFD")].map((part) => part.codePointAt(0)!);
148
+ if (parts.length > 1 || parts[0] !== codePoint) {
149
+ if (parts.every(hasGlyph)) {
150
+ decomposed.push(...parts);
151
+ continue;
152
+ }
153
+ }
154
+ decomposed.push(codePoint);
155
+ }
156
+ if (allSimple) return decomposed;
157
+ // A stable sort of each run of marks with a class that is not 0
158
+ for (let i = 1; i < decomposed.length; i++) {
159
+ for (let j = i; j > 0; j--) {
160
+ const before = combiningClass(decomposed[j - 1]!);
161
+ const after = combiningClass(decomposed[j]!);
162
+ if (after === 0 || before <= after) break;
163
+ [decomposed[j - 1], decomposed[j]] = [decomposed[j]!, decomposed[j - 1]!];
164
+ }
165
+ }
166
+ const out: number[] = decomposed.slice(0, 1);
167
+ let starter = 0;
168
+ for (let i = 1; i < decomposed.length; i++) {
169
+ const codePoint = decomposed[i]!;
170
+ if (isMark(codePoint) && (starter === out.length - 1 || combiningClass(out[out.length - 1]!) < combiningClass(codePoint))) {
171
+ const composed = compose(out[starter]!, codePoint);
172
+ if (composed !== undefined && hasGlyph(composed)) {
173
+ out[starter] = composed;
174
+ continue;
175
+ }
176
+ }
177
+ out.push(codePoint);
178
+ if (combiningClass(codePoint) === 0) starter = out.length - 1;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /** This function gives the canonical composite of two code points, or `undefined`. A mark does not compose with a mark. */
184
+ function compose(first: number, second: number): number | undefined {
185
+ if (isMark(first)) return undefined;
186
+ const composed = [(String.fromCodePoint(first) + String.fromCodePoint(second)).normalize("NFC")];
187
+ const [single, ...rest] = [...composed[0]!];
188
+ return rest.length === 0 && single !== undefined ? single.codePointAt(0) : undefined;
189
+ }
190
+
191
+ // The syllable machine, with one letter for each category
192
+ const letters = new Map<number, string>([
193
+ [X, "x"], [C, "c"], [V, "v"], [N, "n"], [H, "h"], [ZWNJ, "w"], [ZWJ, "j"], [M, "m"], [SM, "s"], [A, "a"], [PLACEHOLDER, "p"],
194
+ [DOTTEDCIRCLE, "d"], [RS, "r"], [MPST, "q"], [REPHA, "f"], [RA, "R"], [CM, "k"], [SYMBOL, "y"], [CS, "S"], [SMPST, "t"],
195
+ ]);
196
+ const c = "[cR]";
197
+ const n = "(?:w?r)?(?:nn?)?";
198
+ const z = "[wj]";
199
+ const reph = "(?:Rh|f)";
200
+ const sm = "[st]";
201
+ const cn = `${c}j?${n}`;
202
+ const matraGroup = `${z}*(?:m|${sm}?q)n?h?`;
203
+ const syllableTail = `(?:${z}?${sm}${sm}?w?)?a*`;
204
+ const halantGroup = `(?:${z}?h(?:jn?)?)`;
205
+ const finalHalantGroup = `(?:${halantGroup}|hw)`;
206
+ const complexSyllableTail = `(?:${halantGroup}${cn})*k?(?:${finalHalantGroup}|(?:${matraGroup})*)${syllableTail}`;
207
+ /** The rules of the machine in the order of HarfBuzz. A tie of two lengths goes to the first rule. */
208
+ const machine: readonly (readonly [RegExp, number])[] = (
209
+ [
210
+ [`[fS]?${cn}${complexSyllableTail}`, CONSONANT_SYLLABLE],
211
+ [`${reph}?v${n}(?:j|${complexSyllableTail})`, VOWEL_SYLLABLE],
212
+ [`(?:[fS]?p|${reph}?d)${n}${complexSyllableTail}`, STANDALONE_CLUSTER],
213
+ [`yn?${syllableTail}`, SYMBOL_CLUSTER],
214
+ ["t", NON_INDIC_CLUSTER],
215
+ [`${reph}?${n}${complexSyllableTail}`, BROKEN_CLUSTER],
216
+ ["[^]", NON_INDIC_CLUSTER],
217
+ ] as const
218
+ ).map(([rule, type]) => [new RegExp(`^(?:${rule})$`), type] as const);
219
+
220
+ /**
221
+ * This function divides the glyphs into syllables, as the machine of HarfBuzz does. At each position, it takes the
222
+ * longest match of the rules. Each glyph gets the syllable byte. Its high 4 bits are a serial number from 1 to 15, and
223
+ * its low 4 bits are the type. The function tells if a syllable is a broken cluster.
224
+ */
225
+ function findSyllables(infos: GlyphInfo[]): boolean {
226
+ const text = infos.map((info) => letters.get(info.category) ?? "x").join("");
227
+ let serial = 1;
228
+ let broken = false;
229
+ let at = 0;
230
+ while (at < text.length) {
231
+ // No rule but the last one matches the category X, so a syllable stops before it
232
+ const next = text.indexOf("x", at + 1);
233
+ let length = 1;
234
+ let type = NON_INDIC_CLUSTER;
235
+ search: for (let end = next < 0 ? text.length : next; end > at; end--) {
236
+ const piece = text.slice(at, end);
237
+ for (const [rule, kind] of machine) {
238
+ if (rule.test(piece)) {
239
+ length = end - at;
240
+ type = kind;
241
+ break search;
242
+ }
243
+ }
244
+ }
245
+ if (type === BROKEN_CLUSTER) broken = true;
246
+ for (let i = at; i < at + length; i++) infos[i]!.syllable = (serial << 4) | type;
247
+ serial = serial === 15 ? 1 : serial + 1;
248
+ at += length;
249
+ }
250
+ return broken;
251
+ }
252
+
253
+ /** The types of the syllables of the Indic machine of HarfBuzz. */
254
+ export type IndicSyllableType = "consonant" | "vowel" | "standalone" | "symbol" | "broken" | "non-indic";
255
+ const syllableTypes: readonly IndicSyllableType[] = ["consonant", "vowel", "standalone", "symbol", "broken", "non-indic"];
256
+
257
+ /**
258
+ * This function divides a text into the syllables of the Indic machine of HarfBuzz. It does not normalize the text and
259
+ * does not insert dotted circles.
260
+ */
261
+ export function indicSyllables(text: string): { readonly text: string; readonly type: IndicSyllableType }[] {
262
+ const characters = [...text];
263
+ const infos = characters.map((character): GlyphInfo => {
264
+ const codePoint = character.codePointAt(0)!;
265
+ const [category, position] = indicProperties(codePoint);
266
+ return { mask: GLOBAL, syllable: 0, props: 0, category, position, codePoint };
267
+ });
268
+ findSyllables(infos);
269
+ return syllables(infos).map(([start, end]) => ({ text: characters.slice(start, end).join(""), type: syllableTypes[infos[start]!.syllable & 0x0f]! }));
270
+ }
271
+
272
+ /** It is true when the glyph made no ligature and its category is one of `categories`. A ligature has no category. */
273
+ function isOneOf(info: GlyphInfo, categories: readonly number[]): boolean {
274
+ return (info.props & LIGATED) === 0 && categories.includes(info.category);
275
+ }
276
+
277
+ const consonants = [C, CS, RA, CM, V, PLACEHOLDER, DOTTEDCIRCLE];
278
+ const joiners = [ZWJ, ZWNJ];
279
+ const isConsonant = (info: GlyphInfo): boolean => isOneOf(info, consonants);
280
+ const isJoiner = (info: GlyphInfo): boolean => isOneOf(info, joiners);
281
+ const isHalant = (info: GlyphInfo): boolean => isOneOf(info, [H]);
282
+ const ligatedAndDidntMultiply = (info: GlyphInfo): boolean => (info.props & (LIGATED | MULTIPLIED)) === LIGATED;
283
+
284
+ /** The basic features, in the order of their stages before the final reordering. The value is true for a global feature. */
285
+ const basicFeatures: readonly (readonly [string, boolean])[] = [
286
+ ["nukt", true], ["akhn", true], ["rphf", false], ["rkrf", true], ["pref", false], ["blwf", false],
287
+ ["abvf", false], ["half", false], ["pstf", false], ["vatu", true], ["cjct", true],
288
+ ];
289
+ /** The features of the last stage, after the final reordering. They match only in a syllable. */
290
+ const finalFeatures: readonly string[] = ["init", "pres", "abvs", "blws", "psts", "haln"];
291
+ /** The mask bit of the global features. Each glyph has it. */
292
+ const GLOBAL = 1;
293
+ /** The mask bits of the features that apply only to some glyphs of a syllable. */
294
+ const featureBits = new Map<string, number>([["rphf", 2], ["pref", 4], ["blwf", 8], ["abvf", 16], ["half", 32], ["pstf", 64], ["init", 128]]);
295
+
296
+ /** The glyphs of a text and the data of each glyph. The two arrays change together. */
297
+ interface Run {
298
+ readonly glyphs: number[];
299
+ readonly infos: GlyphInfo[];
300
+ }
301
+
302
+ /** This function moves the glyph at `from` to `to`. The glyphs between them move by one position. */
303
+ function move(run: Run, from: number, to: number): void {
304
+ const [glyph] = run.glyphs.splice(from, 1);
305
+ const [info] = run.infos.splice(from, 1);
306
+ run.glyphs.splice(to, 0, glyph!);
307
+ run.infos.splice(to, 0, info!);
308
+ }
309
+
310
+ function reverse(run: Run, start: number, end: number): void {
311
+ run.glyphs.splice(start, end - start, ...run.glyphs.slice(start, end).reverse());
312
+ run.infos.splice(start, end - start, ...run.infos.slice(start, end).reverse());
313
+ }
314
+
315
+ /** This function gives the ranges of the syllables, as `foreach_syllable` of HarfBuzz. */
316
+ function syllables(infos: readonly GlyphInfo[]): [number, number][] {
317
+ const ranges: [number, number][] = [];
318
+ let start = 0;
319
+ for (let i = 1; i <= infos.length; i++) {
320
+ if (i === infos.length || infos[i]!.syllable !== infos[start]!.syllable) {
321
+ ranges.push([start, i]);
322
+ start = i;
323
+ }
324
+ }
325
+ return infos.length === 0 ? [] : ranges;
326
+ }
327
+
328
+ /**
329
+ * This function makes the function that gives the glyphs of a word with the Indic shaper. The features `features` are the
330
+ * features of the page. The shaper applies `locl` and `ccmp` before it reorders the syllables, and the other features of
331
+ * the page after its own features. It does not apply `liga`.
332
+ */
333
+ export function indicShaper(font: Font, script: string, features: readonly string[]): (word: string) => number[] {
334
+ const lookupsOf = (tags: readonly string[]) => font.substitutions(script, tags);
335
+ const maskOf = (tag: string): number => (lookupsOf([tag]).length === 0 ? 0 : featureBits.get(tag) ?? GLOBAL);
336
+ const [rphfMask, prefMask, blwfMask, abvfMask, halfMask, pstfMask, initMask] = ["rphf", "pref", "blwf", "abvf", "half", "pstf", "init"].map(maskOf) as [number, number, number, number, number, number, number];
337
+ const would = (tag: string, glyphs: readonly number[]): boolean => lookupsOf([tag]).some((lookup) => lookup.wouldApply(glyphs));
338
+ const virama = font.glyph(0x94d);
339
+ const dottedCircle = font.glyph(0x25cc);
340
+ const positions = new Map<number, number>();
341
+ /** This function gives the position of a consonant from the below-base, post-base and pre-base forms of the font. */
342
+ const consonantPosition = (consonant: number): number => {
343
+ let position = positions.get(consonant);
344
+ if (position === undefined) {
345
+ const before = [virama, consonant];
346
+ const after = [consonant, virama];
347
+ const either = (tag: string) => would(tag, before) || would(tag, after);
348
+ position = either("blwf") || either("vatu") ? BELOW_C : either("pstf") || either("pref") ? POST_C : BASE_C;
349
+ positions.set(consonant, position);
350
+ }
351
+ return position;
352
+ };
353
+ const first = lookupsOf(["locl", "ccmp"]);
354
+ const basic = basicFeatures.map(([tag, global]) => ({ lookups: lookupsOf([tag]), mask: global ? GLOBAL : featureBits.get(tag)! }));
355
+ const extra = features.filter((tag) => !["ccmp", "locl", "liga"].includes(tag) && !finalFeatures.includes(tag));
356
+ const last = lookupsOf([...finalFeatures, ...extra]).map((lookup) => ({
357
+ lookup,
358
+ mask: lookup.features.reduce((mask, tag) => mask | (tag === "init" ? initMask : GLOBAL), 0),
359
+ perSyllable: lookup.features.some((tag) => finalFeatures.includes(tag)),
360
+ }));
361
+
362
+ /** This function reorders one syllable before the basic features, and gives the masks of its glyphs. */
363
+ const initialReordering = (run: Run, start: number, end: number): void => {
364
+ const { glyphs, infos } = run;
365
+ let base = end;
366
+ let hasReph = false;
367
+ let limit = start;
368
+ if (rphfMask !== 0 && start + 3 <= end && !isJoiner(infos[start + 2]!) && would("rphf", [glyphs[start]!, glyphs[start + 1]!])) {
369
+ limit += 2;
370
+ while (limit < end && isJoiner(infos[limit]!)) limit++;
371
+ base = start;
372
+ hasReph = true;
373
+ }
374
+ // The base is the last consonant that has no below-base or post-base form
375
+ let seenBelow = false;
376
+ for (let i = end - 1; i >= limit; i--) {
377
+ const info = infos[i]!;
378
+ if (isConsonant(info)) {
379
+ base = i;
380
+ if (info.position !== BELOW_C && (info.position !== POST_C || seenBelow)) break;
381
+ if (info.position === BELOW_C) seenBelow = true;
382
+ } else if (start < i && info.category === ZWJ && infos[i - 1]!.category === H) break;
383
+ }
384
+ if (hasReph && base === start && limit - base <= 2) hasReph = false;
385
+ for (let i = start; i < base; i++) infos[i]!.position = Math.min(PRE_C, infos[i]!.position);
386
+ if (base < end) infos[base]!.position = BASE_C;
387
+ if (hasReph) infos[start]!.position = RA_TO_BECOME_REPH;
388
+ // A joiner, a nukta or a halant goes with the glyph before it
389
+ let lastPosition = START;
390
+ for (let i = start; i < end; i++) {
391
+ const info = infos[i]!;
392
+ if ([ZWJ, ZWNJ, N, RS, CM, H].includes(info.category)) {
393
+ info.position = lastPosition;
394
+ if (info.category === H && info.position === PRE_M) {
395
+ for (let j = i; j > start; j--) {
396
+ if (infos[j - 1]!.position !== PRE_M) {
397
+ info.position = infos[j - 1]!.position;
398
+ break;
399
+ }
400
+ }
401
+ }
402
+ } else if (info.position !== SMVD) {
403
+ if (info.category === MPST && i > start && infos[i - 1]!.category === SM) infos[i - 1]!.position = info.position;
404
+ lastPosition = info.position;
405
+ }
406
+ }
407
+ // A post-base consonant takes the glyphs before it
408
+ let previous = base;
409
+ for (let i = base + 1; i < end; i++) {
410
+ if (isConsonant(infos[i]!)) {
411
+ for (let j = previous + 1; j < i; j++) if (infos[j]!.position < SMVD) infos[j]!.position = infos[i]!.position;
412
+ previous = i;
413
+ } else if (infos[i]!.category === M || infos[i]!.category === MPST) previous = i;
414
+ }
415
+ const order = glyphs.slice(start, end).map((glyph, k) => ({ glyph, info: infos[start + k]! })).sort((a, b) => a.info.position - b.info.position);
416
+ order.forEach(({ glyph, info }, k) => {
417
+ glyphs[start + k] = glyph;
418
+ infos[start + k] = info;
419
+ });
420
+ base = end;
421
+ let firstLeftMatra = end;
422
+ let lastLeftMatra = end;
423
+ for (let i = start; i < end; i++) {
424
+ if (infos[i]!.position === BASE_C) {
425
+ base = i;
426
+ break;
427
+ }
428
+ if (infos[i]!.position === PRE_M) {
429
+ if (firstLeftMatra === end) firstLeftMatra = i;
430
+ lastLeftMatra = i;
431
+ }
432
+ }
433
+ if (firstLeftMatra < lastLeftMatra) {
434
+ reverse(run, firstLeftMatra, lastLeftMatra + 1);
435
+ let i = firstLeftMatra;
436
+ for (let j = i; j <= lastLeftMatra; j++) {
437
+ if (infos[j]!.category === M || infos[j]!.category === MPST) {
438
+ reverse(run, i, j + 1);
439
+ i = j + 1;
440
+ }
441
+ }
442
+ }
443
+ for (let i = start; i < end && infos[i]!.position === RA_TO_BECOME_REPH; i++) infos[i]!.mask |= rphfMask;
444
+ for (let i = start; i < base; i++) infos[i]!.mask |= halfMask | blwfMask;
445
+ for (let i = base + 1; i < end; i++) infos[i]!.mask |= blwfMask | abvfMask | pstfMask;
446
+ if (prefMask !== 0 && base + 2 < end) {
447
+ for (let i = base + 1; i + 1 < end; i++) {
448
+ if (would("pref", [glyphs[i]!, glyphs[i + 1]!])) {
449
+ infos[i]!.mask |= prefMask;
450
+ infos[i + 1]!.mask |= prefMask;
451
+ break;
452
+ }
453
+ }
454
+ }
455
+ // A ZWNJ stops the half forms of the glyphs before it
456
+ for (let i = start + 1; i < end; i++) {
457
+ if (!isJoiner(infos[i]!) || infos[i]!.category !== ZWNJ) continue;
458
+ let j = i;
459
+ do {
460
+ j--;
461
+ infos[j]!.mask &= ~halfMask;
462
+ } while (j > start && !isConsonant(infos[j]!));
463
+ }
464
+ };
465
+
466
+ /** This function moves the pre-base matra and the reph of one syllable after the basic features. */
467
+ const finalReordering = (run: Run, start: number, end: number): void => {
468
+ const { glyphs, infos } = run;
469
+ for (let i = start; i < end; i++) {
470
+ // A virama that came out of a ligature is a halant again
471
+ if (virama !== 0 && glyphs[i] === virama && (infos[i]!.props & (LIGATED | MULTIPLIED)) === (LIGATED | MULTIPLIED)) {
472
+ infos[i]!.category = H;
473
+ infos[i]!.props &= ~(LIGATED | MULTIPLIED);
474
+ }
475
+ }
476
+ let tryPref = prefMask !== 0;
477
+ let base = start;
478
+ for (; base < end; base++) {
479
+ if (infos[base]!.position < BASE_C) continue;
480
+ if (tryPref && base + 1 < end) {
481
+ for (let i = base + 1; i < end; i++) {
482
+ if ((infos[i]!.mask & prefMask) === 0) continue;
483
+ if (!((infos[i]!.props & SUBSTITUTED) !== 0 && ligatedAndDidntMultiply(infos[i]!))) {
484
+ base = i;
485
+ while (base < end && isHalant(infos[base]!)) base++;
486
+ if (base < end) infos[base]!.position = BASE_C;
487
+ tryPref = false;
488
+ }
489
+ break;
490
+ }
491
+ if (base === end) break;
492
+ }
493
+ if (start < base && infos[base]!.position > BASE_C) base--;
494
+ break;
495
+ }
496
+ if (base === end && start < base && isOneOf(infos[base - 1]!, [ZWJ])) base--;
497
+ if (base < end) while (start < base && isOneOf(infos[base]!, [N, H])) base--;
498
+
499
+ // A pre-base matra goes after the last halant before the base
500
+ if (start + 1 < end && start < base) {
501
+ let newPosition = base === end ? base - 2 : base - 1;
502
+ for (;;) {
503
+ while (newPosition > start && !isOneOf(infos[newPosition]!, [M, MPST, H])) newPosition--;
504
+ if (isHalant(infos[newPosition]!) && infos[newPosition]!.position !== PRE_M) {
505
+ if (newPosition + 1 < end && infos[newPosition + 1]!.category === ZWJ && newPosition > start) {
506
+ newPosition--;
507
+ continue;
508
+ }
509
+ } else newPosition = start;
510
+ break;
511
+ }
512
+ if (start < newPosition && infos[newPosition]!.position !== PRE_M) {
513
+ for (let i = newPosition; i > start; i--) {
514
+ if (infos[i - 1]!.position !== PRE_M) continue;
515
+ const oldPosition = i - 1;
516
+ if (oldPosition < base && base <= newPosition) base--;
517
+ move(run, oldPosition, newPosition);
518
+ newPosition--;
519
+ }
520
+ }
521
+ }
522
+
523
+ // The reph goes after the first halant before the base, or else at the end of the syllable before the signs
524
+ if (start + 1 < end && infos[start]!.position === RA_TO_BECOME_REPH && (infos[start]!.category === REPHA) !== ligatedAndDidntMultiply(infos[start]!)) {
525
+ let newPosition = start + 1;
526
+ while (newPosition < base && !isHalant(infos[newPosition]!)) newPosition++;
527
+ if (newPosition < base && isHalant(infos[newPosition]!)) {
528
+ if (newPosition + 1 < base && isJoiner(infos[newPosition + 1]!)) newPosition++;
529
+ } else {
530
+ newPosition = end - 1;
531
+ while (newPosition > start && infos[newPosition]!.position === SMVD) newPosition--;
532
+ if (isHalant(infos[newPosition]!)) {
533
+ for (let i = base + 1; i < newPosition; i++) if (infos[i]!.category === M || infos[i]!.category === MPST) newPosition--;
534
+ }
535
+ }
536
+ move(run, start, newPosition);
537
+ if (start < base && base <= newPosition) base--;
538
+ }
539
+
540
+ // A pre-base form of a consonant goes before the base
541
+ if (tryPref && base + 1 < end) {
542
+ for (let i = base + 1; i < end; i++) {
543
+ if ((infos[i]!.mask & prefMask) === 0) continue;
544
+ if (ligatedAndDidntMultiply(infos[i]!)) {
545
+ let newPosition = base;
546
+ while (newPosition > start && !isOneOf(infos[newPosition - 1]!, [M, MPST, H])) newPosition--;
547
+ if (newPosition > start && isHalant(infos[newPosition - 1]!) && newPosition < end && isJoiner(infos[newPosition]!)) newPosition++;
548
+ move(run, i, newPosition);
549
+ if (newPosition <= base && base < i) base++;
550
+ }
551
+ break;
552
+ }
553
+ }
554
+ if (infos[start]!.position === PRE_M && (start === 0 || !letterOrMark.test(String.fromCodePoint(infos[start - 1]!.codePoint)))) infos[start]!.mask |= initMask;
555
+ };
556
+
557
+ return (word: string): number[] => {
558
+ const codePoints = normalize(vowelConstraints([...word].map((character) => character.codePointAt(0)!)), (codePoint) => font.glyph(codePoint) !== 0);
559
+ const run: Run = { glyphs: codePoints.map((codePoint) => font.glyph(codePoint)), infos: [] };
560
+ for (const codePoint of codePoints) {
561
+ const [category, position] = indicProperties(codePoint);
562
+ run.infos.push({ mask: GLOBAL, syllable: 0, props: 0, category, position, codePoint });
563
+ }
564
+ const broken = findSyllables(run.infos);
565
+ for (const lookup of first) lookup.applyInfos(run.glyphs, run.infos, GLOBAL, true);
566
+ if (virama !== 0) for (let i = 0; i < run.infos.length; i++) if (run.infos[i]!.position === BASE_C) run.infos[i]!.position = consonantPosition(run.glyphs[i]!);
567
+ if (broken && dottedCircle !== 0) {
568
+ // A broken cluster starts with a dotted circle
569
+ for (let i = run.infos.length - 1; i >= 0; i--) {
570
+ const info = run.infos[i]!;
571
+ if ((info.syllable & 0x0f) !== BROKEN_CLUSTER || (i > 0 && run.infos[i - 1]!.syllable === info.syllable)) continue;
572
+ run.glyphs.splice(i, 0, dottedCircle);
573
+ run.infos.splice(i, 0, { mask: info.mask, syllable: info.syllable, props: 0, category: DOTTEDCIRCLE, position: END, codePoint: 0x25cc });
574
+ }
575
+ }
576
+ for (const [start, end] of syllables(run.infos)) {
577
+ const type = run.infos[start]!.syllable & 0x0f;
578
+ if (type !== SYMBOL_CLUSTER && type !== NON_INDIC_CLUSTER) initialReordering(run, start, end);
579
+ }
580
+ for (const { lookups, mask } of basic) for (const lookup of lookups) lookup.applyInfos(run.glyphs, run.infos, mask, true);
581
+ for (const [start, end] of syllables(run.infos)) finalReordering(run, start, end);
582
+ for (const { lookup, mask, perSyllable } of last) lookup.applyInfos(run.glyphs, run.infos, mask, perSyllable);
583
+ return run.glyphs;
584
+ };
585
+ }