Repository navigation
Expand file tree
/
Copy pathrtl-core.js
More file actions
305 lines (282 loc) · 11.8 KB
/
Copy pathrtl-core.js
File metadata and controls
305 lines (282 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
// rtl-core.js -- pure, DOM-free RTL/LaTeX detection logic.
//
// SOURCE OF TRUTH for the detection engine. tools/build-payload.ps1 inlines this
// file into the injected IIFE inside patch.ps1 (stripping the module.exports guard
// at the bottom); test/rtl-core.test.js requires it directly. Keep it DOM-free.
'use strict';
// Strong-RTL code-point ranges, [lo, hi] inclusive. Covers living RTL scripts plus
// common historic/astral ones and the explicit RTL bidi controls (RLM/RLE/RLO/RLI),
// matching claude.ai's native detector. Tested against code points (codePointAt),
// not UTF-16 code units, so astral blocks like Adlam work.
var RTL_RANGES = [
[0x0590, 0x05FF], // Hebrew
[0x0600, 0x06FF], // Arabic
[0x0700, 0x074F], // Syriac
[0x0750, 0x077F], // Arabic Supplement
[0x0780, 0x07BF], // Thaana
[0x07C0, 0x07FF], // NKo
[0x0800, 0x083F], // Samaritan
[0x0840, 0x085F], // Mandaic
[0x0860, 0x086F], // Syriac Supplement
[0x0870, 0x089F], // Arabic Extended-B
[0x08A0, 0x08FF], // Arabic Extended-A
[0x200F, 0x200F], // Right-to-Left Mark (RLM)
[0x202B, 0x202B], // Right-to-Left Embedding (RLE)
[0x202E, 0x202E], // Right-to-Left Override (RLO)
[0x2067, 0x2067], // Right-to-Left Isolate (RLI)
[0xFB1D, 0xFB4F], // Hebrew presentation forms
[0xFB50, 0xFDFF], // Arabic presentation forms-A
[0xFE70, 0xFEFF], // Arabic presentation forms-B
[0x10800, 0x1083F], // Cypriot Syllabary block (incl. early RTL scripts)
[0x10840, 0x1085F], // Imperial Aramaic
[0x10A00, 0x10A5F], // Kharoshthi
[0x10E60, 0x10E7F], // Rumi Numeral Symbols
[0x1E800, 0x1E8DF], // Mende Kikakui
[0x1E900, 0x1E95F], // Adlam
[0x1EE00, 0x1EEFF] // Arabic Mathematical Alphabetic Symbols
];
function isRTL(cp) {
for (var i = 0; i < RTL_RANGES.length; i++) {
if (cp >= RTL_RANGES[i][0] && cp <= RTL_RANGES[i][1]) return true;
}
return false;
}
function hasRTL(text) {
if (!text) return false;
for (var i = 0; i < text.length;) {
var cp = text.codePointAt(i);
if (isRTL(cp)) return true;
i += cp > 0xFFFF ? 2 : 1;
}
return false;
}
// Direction of the first strong character: 'rtl', 'ltr', or null (no strong char).
function firstStrong(text) {
if (!text) return null;
for (var i = 0; i < text.length;) {
var cp = text.codePointAt(i);
if (isRTL(cp)) return 'rtl';
// ASCII Latin letters are strong-LTR.
if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) return 'ltr';
i += cp > 0xFFFF ? 2 : 1;
}
return null;
}
// Majority script: strong-RTL code points vs Latin letters. Last-resort tie-breaker
// when first-strong says LTR but RTL characters exist -- an English sentence quoting
// a Hebrew word stays LTR; a Hebrew paragraph opening with a Latin run flips RTL.
function rtlMajority(text) {
if (!text) return false;
var r = 0, l = 0;
for (var i = 0; i < text.length;) {
var cp = text.codePointAt(i);
if (isRTL(cp)) r++;
else if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) l++;
i += cp > 0xFFFF ? 2 : 1;
}
return r > l;
}
// Remove leading LTR-only noise (filenames, URLs, paths, backtick-code) so a Hebrew
// sentence that starts with "foo.js" still detects as RTL.
function stripLeadingLTR(text) {
return text
.replace(/^[\s]*(?:[\w.\-]+\.[\w]{1,5})\s*/g, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[\w.\-]+[\/\\][\w.\-\/\\]+/g, '')
.replace(/`[^`]+`/g, '');
}
// A "$...$" body is math only with a real LaTeX signal (currency guard: "$5.99" stays text).
var LATEX_SIGNAL = /[\\^_{}]|\b(?:frac|sqrt|sum|prod|int|lim|infty|cdot|times|div|leq|geq|neq|approx|partial|nabla|alpha|beta|gamma|delta|theta|lambda|mu|pi|sigma|omega|matrix|begin|end|left|right|text|mathbb|mathcal|vec|hat|bar|overline|underline)\b/;
function hasLatexSignal(body) {
return LATEX_SIGNAL.test(body);
}
// Find math regions as [start, end) index pairs. Unambiguous delimiters
// ($$...$$, \[...\], \(...\)) always count; single $...$ only with a LaTeX signal
// and only outside already-claimed regions.
function findLatexRanges(text) {
var ranges = [];
if (!text) return ranges;
function claim(re, requireSignal, bodyStart, bodyEnd) {
var m;
re.lastIndex = 0;
while ((m = re.exec(text)) !== null) {
var start = m.index;
var end = m.index + m[0].length;
if (overlaps(start, end)) continue;
if (requireSignal) {
var body = m[0].slice(bodyStart, m[0].length - bodyEnd);
if (!hasLatexSignal(body)) continue;
}
ranges.push([start, end]);
}
}
function overlaps(s, e) {
for (var i = 0; i < ranges.length; i++) {
if (s < ranges[i][1] && e > ranges[i][0]) return true;
}
return false;
}
// Claim the unambiguous, greedier delimiters first.
claim(/\$\$[\s\S]+?\$\$/g, false, 0, 0);
claim(/\\\[[\s\S]+?\\\]/g, false, 0, 0);
claim(/\\\([\s\S]+?\\\)/g, false, 0, 0);
claim(/\$[^$\n]+?\$/g, true, 1, 1); // single $...$: no newline, must carry a LaTeX signal
ranges.sort(function (a, b) { return a[0] - b[0]; });
return ranges;
}
// --- BARE NUMERIC / ARITHMETIC ISOLATION ---
// Claude often writes arithmetic without LaTeX delimiters ("2 + 3 = 5"); inside an
// RTL paragraph the bidi algorithm mirrors it to "5 = 3 + 2". findMathRanges marks
// such runs so the DOM can isolate them LTR.
//
// Operator characters proving a run is a genuine expression. Built with
// String.fromCharCode so the SOURCE stays pure ASCII (patch.ps1 is BOM-less and
// PowerShell 5.1 corrupts non-ASCII source bytes); '-' is escaped for the regex
// class. Codes: U+00D7 U+00F7 U+00B1 U+2212 U+2264 U+2265 U+2260 U+2248 U+2192
// U+00B7 U+2022 U+2219 U+2217 U+22C5 U+221A.
var MATH_OP_CHARS = '+\\-*/=<>%' + String.fromCharCode(
0xD7, 0xF7, 0xB1, 0x2212, 0x2264, 0x2265, 0x2260,
0x2248, 0x2192, 0xB7, 0x2022, 0x2219, 0x2217, 0x22C5, 0x221A);
var MATH_OP_RE = new RegExp('[' + MATH_OP_CHARS + ']');
var MATH_DIGIT_RE = /[0-9]/;
// A token is "mathy" when built only from digits and math punctuation/operators, OR
// it is a single Latin variable letter (x, y, n). Multi-letter Latin tokens (words,
// "3D", "4K") break a run and keep prose out of the island.
var MATH_TOKEN_RE = new RegExp('^(?:[0-9.,:;()\\[\\]{}|' + MATH_OP_CHARS + ']+|[A-Za-z])$');
function isMathyToken(tok) {
return !!tok && MATH_TOKEN_RE.test(tok);
}
// A token may BOUND a run only if it carries an operand (a digit or single Latin
// variable letter). Pure operator/punctuation tokens sit inside but never bound it.
function isOperandToken(tok) {
return MATH_DIGIT_RE.test(tok) || /^[A-Za-z]$/.test(tok);
}
// Find bare numeric/arithmetic runs as [start, end) pairs. A run must be
// whitespace/line delimited, operand-bounded, and contain a digit AND an operator.
// Lone numbers, "$5", Hebrew-glued constructs, dates/IPs, and "1." list markers are
// left alone.
function findMathRanges(text) {
var ranges = [];
if (!text || !MATH_OP_RE.test(text) || !MATH_DIGIT_RE.test(text)) return ranges;
// Scan line by line so a run never spans a newline (each line is its own bidi
// paragraph). `base` is the absolute offset of the current line.
var base = 0;
var lines = text.split('\n');
for (var li = 0; li < lines.length; li++) {
scanLine(lines[li], base);
base += lines[li].length + 1; // +1 for the '\n' removed by split
}
return ranges;
function scanLine(line, off) {
var toks = [];
var re = /\S+/g;
var m;
while ((m = re.exec(line)) !== null) {
toks.push({ v: m[0], start: m.index, end: m.index + m[0].length });
}
var i = 0;
while (i < toks.length) {
if (!isMathyToken(toks[i].v)) { i++; continue; }
var j = i;
while (j + 1 < toks.length && isMathyToken(toks[j + 1].v)) j++;
// Trim non-operand tokens off both ends so the run is operand-bounded.
var a = i, b = j;
while (a <= b && !isOperandToken(toks[a].v)) a++;
while (b >= a && !isOperandToken(toks[b].v)) b--;
if (a <= b) {
var s = off + toks[a].start;
var e = off + toks[b].end;
// Drop sentence punctuation clinging to the ends.
while (e > s && '.,:;'.indexOf(text.charAt(e - 1)) !== -1) e--;
while (e > s && ',:;'.indexOf(text.charAt(s)) !== -1) s++;
var sub = text.slice(s, e);
if (e - s >= 2 && MATH_DIGIT_RE.test(sub) && MATH_OP_RE.test(sub)) {
ranges.push([s, e]);
}
}
i = j + 1;
}
}
}
// Split text into alternating {type:'text'|'math', value} segments. 'math' covers
// LaTeX islands and bare arithmetic; the DOM layer isolates both LTR. LaTeX wins
// when the two overlap.
function segmentText(text) {
var segs = [];
if (!text) return segs;
var ranges = findLatexRanges(text);
var numeric = findMathRanges(text);
for (var n = 0; n < numeric.length; n++) {
var ns = numeric[n][0], ne = numeric[n][1], clash = false;
for (var c = 0; c < ranges.length; c++) {
if (ns < ranges[c][1] && ne > ranges[c][0]) { clash = true; break; }
}
if (!clash) ranges.push(numeric[n]);
}
if (!ranges.length) {
segs.push({ type: 'text', value: text });
return segs;
}
ranges.sort(function (a, b) { return a[0] - b[0]; });
var pos = 0;
for (var i = 0; i < ranges.length; i++) {
if (ranges[i][0] > pos) {
segs.push({ type: 'text', value: text.slice(pos, ranges[i][0]) });
}
segs.push({ type: 'math', value: text.slice(ranges[i][0], ranges[i][1]) });
pos = ranges[i][1];
}
if (pos < text.length) segs.push({ type: 'text', value: text.slice(pos) });
return segs;
}
// Classify a table cell's direction. A cell is RTL if it *contains* any RTL char
// (header labels often start with a Latin term yet belong to a Hebrew column, so
// first-strong is too weak here). Neutral cells return null so they don't sway
// the majority.
function cellDir(text) {
if (hasRTL(text)) return 'rtl';
if (firstStrong(text) === 'ltr') return 'ltr';
return null;
}
// Decide a table's column direction from header / first-column cell dirs (each an
// array of 'rtl'|'ltr'|null). Header wins; first column is the tie-breaker.
// Returns 'rtl' (flip columns) or null (leave LTR).
function tableDirFromCells(headerDirs, firstColDirs) {
// First header is the semantic key column: if it and the first data cell are
// both RTL, it's a Hebrew table regardless of Latin names in later headers.
if (headerDirs && headerDirs[0] === 'rtl' &&
firstColDirs && firstColDirs[0] === 'rtl') return 'rtl';
var h = majorityDir(headerDirs || []);
if (h === 'rtl') return 'rtl';
if (h === 'ltr') return null;
var c = majorityDir(firstColDirs || []);
return c === 'rtl' ? 'rtl' : null;
}
function majorityDir(dirs) {
var r = 0, l = 0;
for (var i = 0; i < dirs.length; i++) {
if (dirs[i] === 'rtl') r++;
else if (dirs[i] === 'ltr') l++;
}
if (r > l) return 'rtl';
if (l > r) return 'ltr';
return null;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
RTL_RANGES: RTL_RANGES,
isRTL: isRTL,
hasRTL: hasRTL,
firstStrong: firstStrong,
rtlMajority: rtlMajority,
stripLeadingLTR: stripLeadingLTR,
LATEX_SIGNAL: LATEX_SIGNAL,
hasLatexSignal: hasLatexSignal,
findLatexRanges: findLatexRanges,
findMathRanges: findMathRanges,
segmentText: segmentText,
cellDir: cellDir,
tableDirFromCells: tableDirFromCells,
majorityDir: majorityDir
};
}