@flighthq/textbidi 0.2.0 → 0.2.1-next.410.a23f189
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.
- package/package.json +2 -2
- package/src/getBidiRuns.test.ts +55 -4
- package/src/resolveBidiLevels.test.ts +164 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flighthq/textbidi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1-next.410.a23f189",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/flighthq/flight.git",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@flighthq/types": "0.2.
|
|
35
|
+
"@flighthq/types": "0.2.1-next.410.a23f189"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"typescript": "^5.3.0"
|
package/src/getBidiRuns.test.ts
CHANGED
|
@@ -4,11 +4,66 @@ import { getBidiRuns } from './getBidiRuns';
|
|
|
4
4
|
|
|
5
5
|
const HEBREW = 'שלום';
|
|
6
6
|
|
|
7
|
+
// Arabic letters (bidi class AL).
|
|
8
|
+
const ARABIC = 'ابتثج';
|
|
9
|
+
|
|
10
|
+
// Bidi control characters.
|
|
11
|
+
const LRE = '';
|
|
12
|
+
const RLE = '';
|
|
13
|
+
const PDF = '';
|
|
14
|
+
|
|
7
15
|
describe('getBidiRuns', () => {
|
|
16
|
+
it('produces distinct level runs for Arabic text with embedded European numbers', () => {
|
|
17
|
+
// RTL paragraph (Arabic leads). Arabic at level 1. European digits: W2 converts EN after AL
|
|
18
|
+
// to AN, I2 bumps AN to level 2. This creates an RTL run, an LTR run for the digits, and
|
|
19
|
+
// another RTL run.
|
|
20
|
+
// ابتثج 123 ابتثج → Arabic(1) space(1) digits(2) space(1) Arabic(1)
|
|
21
|
+
const runs = getBidiRuns(`${ARABIC} 123 ${ARABIC}`, 'auto');
|
|
22
|
+
expect(runs).toEqual([
|
|
23
|
+
{ start: 0, end: 6, level: 1, direction: 'rtl' },
|
|
24
|
+
{ start: 6, end: 9, level: 2, direction: 'ltr' },
|
|
25
|
+
{ start: 9, end: 15, level: 1, direction: 'rtl' },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('produces distinct runs from an explicit RLE embedding in an LTR paragraph', () => {
|
|
30
|
+
// LTR paragraph. RLE pushes next-odd = 1. Embedded Hebrew gets level 1. PDF pops.
|
|
31
|
+
// "ab" + RLE + שלום + PDF + "cd"
|
|
32
|
+
const text = `ab${RLE}${HEBREW}${PDF}cd`;
|
|
33
|
+
const runs = getBidiRuns(text, 'auto');
|
|
34
|
+
// ab(0) + RLE(0) = run level 0. Then שלום at level 1 = RTL run. Then PDF(0) + cd(0) = run level 0.
|
|
35
|
+
expect(runs).toEqual([
|
|
36
|
+
{ start: 0, end: 3, level: 0, direction: 'ltr' },
|
|
37
|
+
{ start: 3, end: 7, level: 1, direction: 'rtl' },
|
|
38
|
+
{ start: 7, end: 10, level: 0, direction: 'ltr' },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('produces distinct runs from an explicit LRE embedding in an RTL paragraph', () => {
|
|
43
|
+
// RTL paragraph. LRE pushes next-even above 1 = 2. Embedded Latin gets level 2. PDF pops.
|
|
44
|
+
// שלום + LRE + "ab" + PDF + שלום
|
|
45
|
+
const text = `${HEBREW}${LRE}ab${PDF}${HEBREW}`;
|
|
46
|
+
const runs = getBidiRuns(text, 'auto');
|
|
47
|
+
// שלום(1) + LRE(1) = run level 1. Then "ab" at level 2 = LTR run. Then PDF(1) + שלום(1) = run level 1.
|
|
48
|
+
expect(runs).toEqual([
|
|
49
|
+
{ start: 0, end: 5, level: 1, direction: 'rtl' },
|
|
50
|
+
{ start: 5, end: 7, level: 2, direction: 'ltr' },
|
|
51
|
+
{ start: 7, end: 12, level: 1, direction: 'rtl' },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns a single rtl run for pure-RTL text', () => {
|
|
56
|
+
expect(getBidiRuns(ARABIC, 'auto')).toEqual([{ start: 0, end: 5, level: 1, direction: 'rtl' }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
8
59
|
it('returns a single ltr run for pure-LTR text', () => {
|
|
9
60
|
expect(getBidiRuns('hello', 'auto')).toEqual([{ start: 0, end: 5, level: 0, direction: 'ltr' }]);
|
|
10
61
|
});
|
|
11
62
|
|
|
63
|
+
it('returns no runs for empty text', () => {
|
|
64
|
+
expect(getBidiRuns('', 'auto')).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
12
67
|
it('splits a mixed string into ltr / rtl / ltr runs with correct ranges', () => {
|
|
13
68
|
// "hello שלום world" — the joining spaces stay at level 0, so they attach to the Latin runs.
|
|
14
69
|
expect(getBidiRuns(`hello ${HEBREW} world`, 'auto')).toEqual([
|
|
@@ -17,8 +72,4 @@ describe('getBidiRuns', () => {
|
|
|
17
72
|
{ start: 10, end: 16, level: 0, direction: 'ltr' },
|
|
18
73
|
]);
|
|
19
74
|
});
|
|
20
|
-
|
|
21
|
-
it('returns no runs for empty text', () => {
|
|
22
|
-
expect(getBidiRuns('', 'auto')).toEqual([]);
|
|
23
|
-
});
|
|
24
75
|
});
|
|
@@ -5,6 +5,23 @@ import { resolveBidiLevels } from './resolveBidiLevels';
|
|
|
5
5
|
// "שלום" — the Hebrew word (four strong-R letters).
|
|
6
6
|
const HEBREW = 'שלום';
|
|
7
7
|
|
|
8
|
+
// Arabic letters (bidi class AL): U+0627 Alef, U+0628 Ba, U+062A Ta, U+062B Tha, U+062C Jeem.
|
|
9
|
+
const ARABIC = 'ابتثج';
|
|
10
|
+
|
|
11
|
+
// Arabic-Indic digits (bidi class AN): U+0660-U+0662 (٠١٢).
|
|
12
|
+
const ARABIC_DIGITS = '٠١٢';
|
|
13
|
+
|
|
14
|
+
// Bidi control characters.
|
|
15
|
+
const LRE = '';
|
|
16
|
+
const RLE = '';
|
|
17
|
+
const PDF = '';
|
|
18
|
+
const LRO = '';
|
|
19
|
+
const RLO = '';
|
|
20
|
+
const LRI = '';
|
|
21
|
+
const RLI = '';
|
|
22
|
+
const FSI = '';
|
|
23
|
+
const PDI = '';
|
|
24
|
+
|
|
8
25
|
describe('resolveBidiLevels', () => {
|
|
9
26
|
it('assigns level 0 to a pure-LTR string', () => {
|
|
10
27
|
expect(Array.from(resolveBidiLevels('hello', 'auto'))).toEqual([0, 0, 0, 0, 0]);
|
|
@@ -14,6 +31,30 @@ describe('resolveBidiLevels', () => {
|
|
|
14
31
|
expect(Array.from(resolveBidiLevels(HEBREW, 'auto'))).toEqual([1, 1, 1, 1]);
|
|
15
32
|
});
|
|
16
33
|
|
|
34
|
+
it('converts Arabic-Indic digits to AN and keeps them at level 1 in an RTL paragraph (W2 bypass)', () => {
|
|
35
|
+
// Arabic letters (AL) followed by Arabic-Indic digits (AN). W2 does not touch AN — only EN after
|
|
36
|
+
// AL becomes AN. The AN stays AN through W3-W7 and I2 bumps it to level 2 (next even above 1).
|
|
37
|
+
// But the digits are preceded by AL text so paragraph level is 1 (RTL).
|
|
38
|
+
// AL AL AL AL AL AN AN AN → all RTL context.
|
|
39
|
+
// After W3 AL→R, I2 on odd level: R stays level 1, AN gets level 2.
|
|
40
|
+
const levels = Array.from(resolveBidiLevels(`${ARABIC}${ARABIC_DIGITS}`, 'auto'));
|
|
41
|
+
expect(levels).toEqual([1, 1, 1, 1, 1, 2, 2, 2]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('converts EN after AL to AN via W2 in an RTL paragraph', () => {
|
|
45
|
+
// Arabic letters followed by European digits: AL AL AL AL AL EN EN EN.
|
|
46
|
+
// W2: the last strong type before the ENs is AL, so EN→AN.
|
|
47
|
+
// W3: AL→R. I2 on odd level: R stays 1, AN gets level 2.
|
|
48
|
+
const levels = Array.from(resolveBidiLevels(`${ARABIC}123`, 'auto'));
|
|
49
|
+
expect(levels).toEqual([1, 1, 1, 1, 1, 2, 2, 2]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('derives an rtl paragraph from a leading strong-R character under auto', () => {
|
|
53
|
+
// First strong char is Hebrew → paragraph level 1; the trailing Latin rises to level 2.
|
|
54
|
+
const levels = Array.from(resolveBidiLevels(`${HEBREW} ab`, 'auto'));
|
|
55
|
+
expect(levels).toEqual([1, 1, 1, 1, 1, 2, 2]);
|
|
56
|
+
});
|
|
57
|
+
|
|
17
58
|
it('embeds an RTL run at an odd level inside an LTR paragraph', () => {
|
|
18
59
|
// "hello שלום world": Latin at level 0, the Hebrew run at level 1, the joining spaces at 0.
|
|
19
60
|
const levels = Array.from(resolveBidiLevels(`hello ${HEBREW} world`, 'auto'));
|
|
@@ -26,20 +67,137 @@ describe('resolveBidiLevels', () => {
|
|
|
26
67
|
expect(levels).toEqual([1, 1, 1, 1, 1, 2, 2, 2]);
|
|
27
68
|
});
|
|
28
69
|
|
|
70
|
+
it('keeps an explicit ltr base at level 0 for pure-LTR text even with a leading number', () => {
|
|
71
|
+
expect(Array.from(resolveBidiLevels('12ab', 'ltr'))).toEqual([0, 0, 0, 0]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps EN as level 0 after L via W7 in an LTR paragraph', () => {
|
|
75
|
+
// "abc 123 def": all LTR paragraph. The EN digits: W7 says EN after L strong type becomes L.
|
|
76
|
+
// So everything stays level 0.
|
|
77
|
+
const levels = Array.from(resolveBidiLevels('abc 123 def', 'auto'));
|
|
78
|
+
expect(levels).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('pushes an LRE embedding to level 2 inside an RTL paragraph (X3)', () => {
|
|
82
|
+
// RTL paragraph (base 1). LRE pushes next-even = 2, PDF pops. The embedded Latin text gets
|
|
83
|
+
// level 2 (LTR). The LRE/PDF themselves become BN and take surrounding levels.
|
|
84
|
+
// שלום + LRE + "ab" + PDF + שלום
|
|
85
|
+
// Paragraph level 1 (Hebrew leads).
|
|
86
|
+
// Levels: [1,1,1,1, 1, 2,2, 1, 1,1,1,1]
|
|
87
|
+
// Hebrew LRE ab PDF Hebrew
|
|
88
|
+
const text = `${HEBREW}${LRE}ab${PDF}${HEBREW}`;
|
|
89
|
+
const levels = Array.from(resolveBidiLevels(text, 'auto'));
|
|
90
|
+
expect(levels).toEqual([1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('pushes an RLE embedding to level 1 inside an LTR paragraph (X2)', () => {
|
|
94
|
+
// LTR paragraph (base 0). RLE pushes next-odd = 1, PDF pops. The embedded Hebrew text gets
|
|
95
|
+
// level 1 (RTL). The RLE/PDF become BN at the surrounding level.
|
|
96
|
+
// "ab" + RLE + שלום + PDF + "cd"
|
|
97
|
+
const text = `ab${RLE}${HEBREW}${PDF}cd`;
|
|
98
|
+
const levels = Array.from(resolveBidiLevels(text, 'auto'));
|
|
99
|
+
// ab=0,0 RLE=0 שלום=1,1,1,1 PDF=0 cd=0,0
|
|
100
|
+
expect(levels).toEqual([0, 0, 0, 1, 1, 1, 1, 0, 0, 0]);
|
|
101
|
+
});
|
|
102
|
+
|
|
29
103
|
it('raises the paragraph level for an explicit rtl base', () => {
|
|
30
104
|
// Same mixed string, base 'rtl': the Latin words rise to level 2, the Hebrew + spaces sit at 1.
|
|
31
105
|
const levels = Array.from(resolveBidiLevels(`hello ${HEBREW} world`, 'rtl'));
|
|
32
106
|
expect(levels).toEqual([2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2]);
|
|
33
107
|
});
|
|
34
108
|
|
|
35
|
-
it('
|
|
36
|
-
|
|
109
|
+
it('resets trailing whitespace to the paragraph level via L1', () => {
|
|
110
|
+
// Hebrew text followed by spaces: paragraph level 1, Hebrew at level 1, but the trailing
|
|
111
|
+
// whitespace resets to paragraph level 1 via L1. In an RTL paragraph the WS is already at 1.
|
|
112
|
+
// For an LTR paragraph with trailing WS after RTL: "abc שלום "
|
|
113
|
+
// LTR paragraph (base 0). Hebrew at level 1. Trailing spaces are WS — L1 resets them to 0.
|
|
114
|
+
const levels = Array.from(resolveBidiLevels(`abc ${HEBREW} `, 'auto'));
|
|
115
|
+
// abc =0,0,0,0 שלום=1,1,1,1 " "=0,0,0 (L1 trailing WS → paragraph level)
|
|
116
|
+
expect(levels).toEqual([0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0]);
|
|
37
117
|
});
|
|
38
118
|
|
|
39
|
-
it('
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
119
|
+
it('resolves an LRI isolate to an even level inside an RTL paragraph (X5b)', () => {
|
|
120
|
+
// RTL paragraph. LRI pushes next-even above current level 1 = 2. The isolated Latin text
|
|
121
|
+
// gets level 2. PDI pops back.
|
|
122
|
+
// שלום + LRI + "ab" + PDI + שלום
|
|
123
|
+
const text = `${HEBREW}${LRI}ab${PDI}${HEBREW}`;
|
|
124
|
+
const levels = Array.from(resolveBidiLevels(text, 'auto'));
|
|
125
|
+
// Hebrew=1,1,1,1 LRI=1 ab=2,2 PDI=1 Hebrew=1,1,1,1
|
|
126
|
+
expect(levels).toEqual([1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('resolves an RLI isolate to an odd level inside an LTR paragraph (X5a)', () => {
|
|
130
|
+
// LTR paragraph. RLI pushes next-odd above current level 0 = 1. The isolated Hebrew text
|
|
131
|
+
// gets level 1. PDI pops back.
|
|
132
|
+
// "ab" + RLI + שלום + PDI + "cd"
|
|
133
|
+
const text = `ab${RLI}${HEBREW}${PDI}cd`;
|
|
134
|
+
const levels = Array.from(resolveBidiLevels(text, 'auto'));
|
|
135
|
+
// ab=0,0 RLI=0 שלום=1,1,1,1 PDI=0 cd=0,0
|
|
136
|
+
expect(levels).toEqual([0, 0, 0, 1, 1, 1, 1, 0, 0, 0]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('resolves an FSI isolate by scoring its content (X5c)', () => {
|
|
140
|
+
// LTR paragraph. FSI scores the enclosed text — Hebrew is first strong, so it acts like RLI.
|
|
141
|
+
// "ab" + FSI + שלום + PDI + "cd" → same as the RLI case: Hebrew at level 1.
|
|
142
|
+
const text = `ab${FSI}${HEBREW}${PDI}cd`;
|
|
143
|
+
const levels = Array.from(resolveBidiLevels(text, 'auto'));
|
|
144
|
+
expect(levels).toEqual([0, 0, 0, 1, 1, 1, 1, 0, 0, 0]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('resolves an FSI with LTR content as LRI (X5c)', () => {
|
|
148
|
+
// RTL paragraph. FSI scores "ab" — first strong is L, so it acts like LRI → next-even = 2.
|
|
149
|
+
// שלום + FSI + "ab" + PDI + שלום
|
|
150
|
+
const text = `${HEBREW}${FSI}ab${PDI}${HEBREW}`;
|
|
151
|
+
const levels = Array.from(resolveBidiLevels(text, 'auto'));
|
|
152
|
+
// Hebrew=1,1,1,1 FSI=1 ab=2,2 PDI=1 Hebrew=1,1,1,1
|
|
153
|
+
expect(levels).toEqual([1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('resolves mixed neutrals between same-direction strong types via N1', () => {
|
|
157
|
+
// "abc!?@def": all LTR. The neutrals (ON class: !?@) sit between L and L, so N1 resolves
|
|
158
|
+
// them to L. Everything stays level 0.
|
|
159
|
+
const levels = Array.from(resolveBidiLevels('abc!?@def', 'auto'));
|
|
160
|
+
expect(levels).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('resolves neutrals between R and R to R via N1 in an RTL paragraph', () => {
|
|
164
|
+
// שלום!?שלום in an RTL paragraph. The neutrals (ON: !?) sit between R and R, so N1 resolves
|
|
165
|
+
// them to R. I2 on odd level: R stays level 1. Everything is level 1.
|
|
166
|
+
const levels = Array.from(resolveBidiLevels(`${HEBREW}!?${HEBREW}`, 'auto'));
|
|
167
|
+
expect(levels).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('resolves neutrals between opposite strong types to the embedding direction via N2', () => {
|
|
171
|
+
// LTR paragraph: "abc!שלום". Neutrals (!) between L and R. N1 requires both sides same
|
|
172
|
+
// direction — they differ, so N2 applies: the neutral gets the embedding direction (L at
|
|
173
|
+
// level 0). The Hebrew still gets level 1.
|
|
174
|
+
const levels = Array.from(resolveBidiLevels(`abc!${HEBREW}`, 'auto'));
|
|
175
|
+
// abc=0,0,0 !=0 שלום=1,1,1,1
|
|
176
|
+
expect(levels).toEqual([0, 0, 0, 0, 1, 1, 1, 1]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('resolves an LRO override forcing all content to L (X6)', () => {
|
|
180
|
+
// LTR paragraph. LRO forces the override direction L onto all characters until PDF.
|
|
181
|
+
// "a" + LRO + שלום + PDF + "b": the Hebrew under LRO is overridden to L class.
|
|
182
|
+
// LRO pushes next-even above 0 = 2 with L override. The overridden Hebrew chars become L at
|
|
183
|
+
// level 2. Then I1 on even level: L stays level 2.
|
|
184
|
+
const text = `a${LRO}${HEBREW}${PDF}b`;
|
|
185
|
+
const levels = Array.from(resolveBidiLevels(text, 'auto'));
|
|
186
|
+
// a=0 LRO=0 שלום=2,2,2,2 PDF=0 b=0
|
|
187
|
+
expect(levels).toEqual([0, 0, 2, 2, 2, 2, 0, 0]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('resolves an RLO override forcing all content to R (X6)', () => {
|
|
191
|
+
// LTR paragraph. RLO pushes next-odd = 1 with R override. The Latin under RLO becomes R at
|
|
192
|
+
// level 1.
|
|
193
|
+
const text = `${HEBREW}${RLO}ab${PDF}${HEBREW}`;
|
|
194
|
+
const levels = Array.from(resolveBidiLevels(text, 'auto'));
|
|
195
|
+
// Paragraph is RTL (Hebrew leads). RLO pushes next-odd above 1 = 3. Override direction R.
|
|
196
|
+
// "ab" under RLO: forced to R at level 3. I2 on odd level: R stays level 3.
|
|
197
|
+
// Actually let me reconsider: paragraph level is 1 (RTL).
|
|
198
|
+
// שלום=1,1,1,1 RLO=1 ab=3,3 PDF=1 שלום=1,1,1,1
|
|
199
|
+
// RLO at level 1 pushes next-odd = 3 with R override. Content at level 3.
|
|
200
|
+
expect(levels).toEqual([1, 1, 1, 1, 1, 3, 3, 1, 1, 1, 1, 1]);
|
|
43
201
|
});
|
|
44
202
|
|
|
45
203
|
it('returns an empty array for empty text', () => {
|